From 77d8a1b0cdd3d31360486fb080f3944232d83b43 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 14:16:22 +0900 Subject: [PATCH 01/42] Add System.Text.Json serializer for ConvertTo-Json via PSJsonSerializerV2 experimental feature --- .../utility/WebCmdlet/ConvertToJsonCommand.cs | 54 +- .../commands/utility/WebCmdlet/JsonObject.cs | 6 + .../WebCmdlet/SystemTextJsonSerializer.cs | 624 ++++++++++++++++++ .../resources/WebCmdletStrings.resx | 3 + .../ExperimentalFeature.cs | 5 + ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 199 ++++++ 6 files changed, 887 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs create mode 100644 test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index 173d999b06d..ad6b098cf7c 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -19,6 +19,11 @@ namespace Microsoft.PowerShell.Commands [OutputType(typeof(string))] public class ConvertToJsonCommand : PSCmdlet, IDisposable { + private const int DefaultDepth = 2; + private const int DefaultDepthV2 = 64; + private const int DepthAllowed = 100; + private const int DepthAllowedV2 = 1000; + /// /// Gets or sets the InputObject property. /// @@ -26,19 +31,35 @@ public class ConvertToJsonCommand : PSCmdlet, IDisposable [AllowNull] public object InputObject { get; set; } - private int _depth = 2; + private int? _depth; private readonly CancellationTokenSource _cancellationSource = new(); /// /// Gets or sets the Depth property. + /// When PSJsonSerializerV2 is enabled: default is 64, max is 1000. + /// Otherwise: default is 2, max is 100. /// [Parameter] - [ValidateRange(0, 100)] + [ValidateRange(0, DepthAllowedV2)] public int Depth { - get { return _depth; } - set { _depth = value; } + get + { + if (_depth.HasValue) + { + return _depth.Value; + } + + return ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2) + ? DefaultDepthV2 + : DefaultDepth; + } + + set + { + _depth = value; + } } /// @@ -109,6 +130,31 @@ protected override void ProcessRecord() _inputObjects.Add(InputObject); } + /// + /// Validate parameters and prepare for processing. + /// + protected override void BeginProcessing() + { + // When PSJsonSerializerV2 is not enabled, enforce the legacy max depth limit + if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2)) + { + if (_depth.HasValue && _depth.Value > DepthAllowed) + { + var errorRecord = new ErrorRecord( + new ArgumentException( + string.Format( + System.Globalization.CultureInfo.CurrentCulture, + WebCmdletStrings.JsonDepthExceedsLimit, + _depth.Value, + DepthAllowed)), + "DepthExceedsLimit", + ErrorCategory.InvalidArgument, + _depth.Value); + ThrowTerminatingError(errorRecord); + } + } + } + /// /// Do the conversion to json and write output. /// diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs index 6506f2bd2ce..784db27605f 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs @@ -483,6 +483,12 @@ private static ICollection PopulateHashTableFromJArray(JArray list, out /// public static string ConvertToJson(object objectToProcess, in ConvertToJsonContext context) { + // Use System.Text.Json when PSJsonSerializerV2 experimental feature is enabled + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2)) + { + return SystemTextJsonSerializer.ConvertToJson(objectToProcess, in context); + } + try { // Pre-process the object so that it serializes the same, except that properties whose diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs new file mode 100644 index 00000000000..689ab983143 --- /dev/null +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs @@ -0,0 +1,624 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Management.Automation; +using System.Management.Automation.Internal; +using System.Numerics; +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Unicode; +using System.Threading; + +using NewtonsoftStringEscapeHandling = Newtonsoft.Json.StringEscapeHandling; + +namespace Microsoft.PowerShell.Commands +{ + /// + /// Provides JSON serialization using System.Text.Json with PowerShell-specific handling. + /// + /// + /// This implementation uses Utf8JsonWriter directly instead of JsonSerializer.Serialize() + /// to provide full control over depth tracking and graceful handling of depth limits. + /// Unlike standard System.Text.Json behavior (which throws on depth exceeded), + /// this implementation converts deep objects to their string representation. + /// + internal static class SystemTextJsonSerializer + { + private static bool s_maxDepthWarningWritten; + + /// + /// Convert an object to JSON string using System.Text.Json. + /// + /// The object to convert. + /// The context for the conversion. + /// A JSON string representation of the object, or null if cancelled. + public static string? ConvertToJson(object? objectToProcess, in JsonObject.ConvertToJsonContext context) + { + try + { + s_maxDepthWarningWritten = false; + + var writerOptions = new JsonWriterOptions + { + Indented = !context.CompressOutput, + Encoder = GetEncoder(context.StringEscapeHandling), + }; + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, writerOptions)) + { + var serializer = new PowerShellJsonWriter( + context.MaxDepth, + context.EnumsAsStrings, + context.Cmdlet, + context.CancellationToken); + serializer.WriteValue(writer, objectToProcess, currentDepth: 0); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + catch (OperationCanceledException) + { + return null; + } + } + + private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escapeHandling) + { + return escapeHandling switch + { + NewtonsoftStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + NewtonsoftStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, + NewtonsoftStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), + _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + } + + /// + /// Writes the max depth warning message once per serialization. + /// + internal static void WriteMaxDepthWarning(int maxDepth, PSCmdlet? cmdlet) + { + if (s_maxDepthWarningWritten || cmdlet is null) + { + return; + } + + s_maxDepthWarningWritten = true; + string message = string.Format( + CultureInfo.CurrentCulture, + WebCmdletStrings.JsonMaxDepthReached, + maxDepth); + cmdlet.WriteWarning(message); + } + } + + /// + /// Writes PowerShell objects to JSON with manual depth tracking. + /// + /// + /// + /// This class writes JSON directly to Utf8JsonWriter instead of using + /// JsonSerializer.Serialize() with custom converters. This approach avoids + /// issues with System.Text.Json's internal depth tracking when converters + /// call Serialize() recursively. + /// + /// + /// Key features: + /// - Manual depth tracking with graceful degradation (string conversion) on depth exceeded + /// - Support for PSObject with extended/adapted properties + /// - Support for non-string dictionary keys (converted via ToString()) + /// - Respects JsonIgnoreAttribute and PowerShell's HiddenAttribute + /// - Special handling for Int64/UInt64 enums (JavaScript precision issue) + /// + /// + internal sealed class PowerShellJsonWriter + { + private readonly int _maxDepth; + private readonly bool _enumsAsStrings; + private readonly PSCmdlet? _cmdlet; + private readonly CancellationToken _cancellationToken; + + public PowerShellJsonWriter( + int maxDepth, + bool enumsAsStrings, + PSCmdlet? cmdlet, + CancellationToken cancellationToken) + { + _maxDepth = maxDepth; + _enumsAsStrings = enumsAsStrings; + _cmdlet = cmdlet; + _cancellationToken = cancellationToken; + } + + #region Main Entry Point + + /// + /// Writes a value to JSON, handling all PowerShell-specific types. + /// + internal void WriteValue(Utf8JsonWriter writer, object? value, int currentDepth) + { + _cancellationToken.ThrowIfCancellationRequested(); + + // Handle null + if (value is null || LanguagePrimitives.IsNull(value)) + { + writer.WriteNullValue(); + return; + } + + // Unwrap PSObject and get base object + PSObject? pso = value as PSObject; + object baseObject = pso?.BaseObject ?? value; + + // Handle special null-like values (NullString, DBNull) + if (TryWriteNullLike(writer, baseObject, pso, currentDepth)) + { + return; + } + + // Handle primitive types (string, numbers, dates, etc.) + if (TryWritePrimitive(writer, baseObject)) + { + return; + } + + // Handle enums + if (baseObject.GetType().IsEnum) + { + WriteEnum(writer, baseObject); + return; + } + + // For complex types, check depth limit + if (currentDepth > _maxDepth) + { + WriteDepthExceeded(writer, baseObject, pso); + return; + } + + // Handle complex types + WriteComplexValue(writer, baseObject, pso, currentDepth); + } + + #endregion + + #region Primitive Types + + /// + /// Attempts to write a primitive value. Returns true if the value was handled. + /// + private static bool TryWritePrimitive(Utf8JsonWriter writer, object value) + { + switch (value) + { + case string s: + writer.WriteStringValue(s); + return true; + + case bool b: + writer.WriteBooleanValue(b); + return true; + + // Integer types + case int i: + writer.WriteNumberValue(i); + return true; + case long l: + writer.WriteNumberValue(l); + return true; + case byte by: + writer.WriteNumberValue(by); + return true; + case sbyte sb: + writer.WriteNumberValue(sb); + return true; + case short sh: + writer.WriteNumberValue(sh); + return true; + case ushort us: + writer.WriteNumberValue(us); + return true; + case uint ui: + writer.WriteNumberValue(ui); + return true; + case ulong ul: + writer.WriteNumberValue(ul); + return true; + + // Floating point types + case double d: + writer.WriteNumberValue(d); + return true; + case float f: + writer.WriteNumberValue(f); + return true; + case decimal dec: + writer.WriteNumberValue(dec); + return true; + + // BigInteger (written as raw number to preserve precision) + case BigInteger bi: + writer.WriteRawValue(bi.ToString(CultureInfo.InvariantCulture)); + return true; + + // Date/time types + case DateTime dt: + writer.WriteStringValue(dt); + return true; + case DateTimeOffset dto: + writer.WriteStringValue(dto); + return true; + + // Other simple types + case Guid g: + writer.WriteStringValue(g); + return true; + case Uri uri: + writer.WriteStringValue(uri.OriginalString); + return true; + case char c: + writer.WriteStringValue(c.ToString()); + return true; + + default: + return false; + } + } + + /// + /// Writes an enum value, handling Int64/UInt64 specially for JavaScript compatibility. + /// + private void WriteEnum(Utf8JsonWriter writer, object value) + { + if (_enumsAsStrings) + { + writer.WriteStringValue(value.ToString()); + return; + } + + // Int64/UInt64 based enums must be written as strings + // because JavaScript cannot represent them precisely + Type underlyingType = Enum.GetUnderlyingType(value.GetType()); + if (underlyingType == typeof(long) || underlyingType == typeof(ulong)) + { + writer.WriteStringValue(value.ToString()); + } + else + { + writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + } + } + + #endregion + + #region Complex Types + + /// + /// Writes a complex value (dictionary, array, or object). + /// + private void WriteComplexValue(Utf8JsonWriter writer, object value, PSObject? pso, int currentDepth) + { + // Handle Newtonsoft.Json JObject (for backward compatibility) + if (value is Newtonsoft.Json.Linq.JObject jObject) + { + WriteDictionary(writer, jObject.ToObject>()!, currentDepth, pso: null); + return; + } + + // Handle dictionaries + if (value is IDictionary dict) + { + WriteDictionary(writer, dict, currentDepth, pso); + return; + } + + // Handle enumerables (arrays, lists, etc.) + if (value is IEnumerable enumerable) + { + WriteArray(writer, enumerable, currentDepth); + return; + } + + // Handle custom objects (classes, structs) + WriteCustomObject(writer, value, pso, currentDepth); + } + + /// + /// Writes a dictionary as a JSON object. + /// + /// + /// Non-string keys are converted to strings via ToString(). + /// This enables serialization of dictionaries like Exception.Data. + /// + private void WriteDictionary(Utf8JsonWriter writer, IDictionary dict, int currentDepth, PSObject? pso) + { + writer.WriteStartObject(); + + foreach (DictionaryEntry entry in dict) + { + string key = entry.Key?.ToString() ?? string.Empty; + writer.WritePropertyName(key); + WriteValue(writer, entry.Value, currentDepth + 1); + } + + if (pso is not null) + { + AppendExtendedProperties(writer, pso, dict, currentDepth, isCustomObject: false); + } + + writer.WriteEndObject(); + } + + /// + /// Writes an enumerable as a JSON array. + /// + private void WriteArray(Utf8JsonWriter writer, IEnumerable enumerable, int currentDepth) + { + writer.WriteStartArray(); + + foreach (object? item in enumerable) + { + WriteValue(writer, item, currentDepth + 1); + } + + writer.WriteEndArray(); + } + + /// + /// Writes a custom object (class or struct) as a JSON object. + /// + private void WriteCustomObject(Utf8JsonWriter writer, object value, PSObject? pso, int currentDepth) + { + writer.WriteStartObject(); + + Type type = value.GetType(); + var writtenProperties = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Write public fields + WriteFields(writer, value, type, currentDepth, writtenProperties); + + // Write public properties + WriteProperties(writer, value, type, currentDepth, writtenProperties); + + // Add extended properties from PSObject + if (pso is not null) + { + AppendExtendedProperties(writer, pso, writtenProperties, currentDepth, isCustomObject: true); + } + + writer.WriteEndObject(); + } + + private void WriteFields( + Utf8JsonWriter writer, + object value, + Type type, + int currentDepth, + HashSet writtenProperties) + { + foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + if (ShouldSkipMember(field)) + { + continue; + } + + object? fieldValue = TryGetFieldValue(field, value); + writer.WritePropertyName(field.Name); + WriteValue(writer, fieldValue, currentDepth + 1); + writtenProperties.Add(field.Name); + } + } + + private void WriteProperties( + Utf8JsonWriter writer, + object value, + Type type, + int currentDepth, + HashSet writtenProperties) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (ShouldSkipMember(property)) + { + continue; + } + + MethodInfo? getter = property.GetGetMethod(); + if (getter is null || getter.GetParameters().Length > 0) + { + continue; + } + + object? propertyValue = TryGetPropertyValue(getter, value); + writer.WritePropertyName(property.Name); + WriteValue(writer, propertyValue, currentDepth + 1); + writtenProperties.Add(property.Name); + } + } + + #endregion + + #region PSObject Support + + /// + /// Handles NullString and DBNull values, which may have extended properties. + /// + private bool TryWriteNullLike(Utf8JsonWriter writer, object value, PSObject? pso, int currentDepth) + { + if (value != System.Management.Automation.Language.NullString.Value && value != DBNull.Value) + { + return false; + } + + if (pso is not null && HasExtendedProperties(pso)) + { + WriteObjectWithNullValue(writer, pso, currentDepth); + } + else + { + writer.WriteNullValue(); + } + + return true; + } + + /// + /// Writes an object with a null base value but with extended properties. + /// + private void WriteObjectWithNullValue(Utf8JsonWriter writer, PSObject pso, int currentDepth) + { + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + AppendExtendedProperties(writer, pso, writtenKeys: null, currentDepth, isCustomObject: false); + writer.WriteEndObject(); + } + + /// + /// Appends extended (and optionally adapted) properties from a PSObject. + /// + private void AppendExtendedProperties( + Utf8JsonWriter writer, + PSObject pso, + object? writtenKeys, + int currentDepth, + bool isCustomObject) + { + // DateTime and String should not have extended properties appended + if (pso.BaseObject is string || pso.BaseObject is DateTime) + { + return; + } + + PSMemberViewTypes viewTypes = isCustomObject + ? PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted + : PSMemberViewTypes.Extended; + + var properties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(viewTypes)); + + foreach (PSPropertyInfo prop in properties) + { + if (IsPropertyAlreadyWritten(prop.Name, writtenKeys)) + { + continue; + } + + object? propValue = TryGetPSPropertyValue(prop); + writer.WritePropertyName(prop.Name); + WriteValue(writer, propValue, currentDepth + 1); + } + } + + private static bool HasExtendedProperties(PSObject pso) + { + var properties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(PSMemberViewTypes.Extended)); + + foreach (var _ in properties) + { + return true; + } + + return false; + } + + private static bool IsPropertyAlreadyWritten(string name, object? writtenKeys) + { + return writtenKeys switch + { + IDictionary dict => dict.Contains(name), + HashSet hashSet => hashSet.Contains(name), + _ => false, + }; + } + + #endregion + + #region Helper Methods + + /// + /// Writes a string representation when max depth is exceeded. + /// + private void WriteDepthExceeded(Utf8JsonWriter writer, object value, PSObject? pso) + { + SystemTextJsonSerializer.WriteMaxDepthWarning(_maxDepth, _cmdlet); + + string stringValue = pso is not null && pso.ImmediateBaseObjectIsEmpty + ? LanguagePrimitives.ConvertTo(pso) + : LanguagePrimitives.ConvertTo(value); + + writer.WriteStringValue(stringValue); + } + + /// + /// Checks if a member should be skipped during serialization. + /// + private static bool ShouldSkipMember(MemberInfo member) + { + return member.IsDefined(typeof(JsonIgnoreAttribute), inherit: true) + || member.IsDefined(typeof(HiddenAttribute), inherit: true); + } + + /// + /// Safely gets a field value, returning null on exception. + /// + private static object? TryGetFieldValue(FieldInfo field, object obj) + { + try + { + return field.GetValue(obj); + } + catch + { + return null; + } + } + + /// + /// Safely gets a property value, returning null on exception. + /// + private static object? TryGetPropertyValue(MethodInfo getter, object obj) + { + try + { + return getter.Invoke(obj, Array.Empty()); + } + catch + { + return null; + } + } + + /// + /// Safely gets a PSPropertyInfo value, returning null on exception. + /// + private static object? TryGetPSPropertyValue(PSPropertyInfo prop) + { + try + { + return prop.Value; + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index cb080d37012..eeb7e1e8b29 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -246,6 +246,9 @@ Resulting JSON is truncated as serialization has exceeded the set depth of {0}. + + The value {0} is not valid for the Depth parameter. The valid range is 0 to {1}. To use higher values, enable the PSJsonSerializerV2 experimental feature. + The WebSession properties were changed between requests forcing all HTTP connections in the session to be recreated. diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 7e17ec43137..40ea668215e 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -22,6 +22,7 @@ public class ExperimentalFeature internal const string EngineSource = "PSEngine"; internal const string PSSerializeJSONLongEnumAsNumber = nameof(PSSerializeJSONLongEnumAsNumber); internal const string PSProfileDSCResource = "PSProfileDSCResource"; + internal const string PSJsonSerializerV2 = nameof(PSJsonSerializerV2); #endregion @@ -111,6 +112,10 @@ static ExperimentalFeature() name: PSSerializeJSONLongEnumAsNumber, description: "Serialize enums based on long or ulong as an numeric value rather than the string representation when using ConvertTo-Json." ), + new ExperimentalFeature( + name: PSJsonSerializerV2, + description: "Use System.Text.Json with improved defaults for ConvertTo-Json: Depth default 64 (was 2), limit 1000 (was 100)." + ), new ExperimentalFeature( name: PSProfileDSCResource, description: "DSC v3 resources for managing PowerShell profile." diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 new file mode 100644 index 00000000000..8f729a143fd --- /dev/null +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -0,0 +1,199 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeDiscovery { + # Check if V2 is enabled using Get-ExperimentalFeature + $script:v2Feature = Get-ExperimentalFeature -Name PSJsonSerializerV2 -ErrorAction SilentlyContinue + $script:isV2Enabled = $script:v2Feature -and $script:v2Feature.Enabled +} + +Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { + Context "Default values and limits" { + It "V2: Default depth should be 64" -Skip:(-not $script:isV2Enabled) { + # Create a 64-level deep object + $obj = @{ level = 0 } + for ($i = 1; $i -le 63; $i++) { + $obj = @{ level = $i; child = $obj } + } + # This should work without truncation at default depth 64 + $json = $obj | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Match '"level":63' + } + + It "V2: Depth up to 1000 should be allowed" -Skip:(-not $script:isV2Enabled) { + { ConvertTo-Json -InputObject @{a=1} -Depth 1000 } | Should -Not -Throw + } + + It "V2: Depth over 1000 should throw" -Skip:(-not $script:isV2Enabled) { + { ConvertTo-Json -InputObject @{a=1} -Depth 1001 } | Should -Throw + } + + It "Legacy: Depth over 100 should throw when V2 is disabled" -Skip:$script:isV2Enabled { + { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw + } + } + + Context "Depth exceeded warning" { + It "V2: Should output warning when depth is exceeded" -Skip:(-not $script:isV2Enabled) { + $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } + $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + } + + It "V2: Should convert deep objects to string when depth exceeded" -Skip:(-not $script:isV2Enabled) { + $inner = [pscustomobject]@{ value = "deep" } + $outer = [pscustomobject]@{ child = $inner } + $json = $outer | ConvertTo-Json -Depth 0 -Compress -WarningVariable warn -WarningAction SilentlyContinue + # At depth 0, child should be converted to string + $json | Should -Match '"child":' + $warn | Should -Not -BeNullOrEmpty + } + } + + Context "Non-string dictionary keys (Issue #5749)" { + It "V2: Should serialize dictionary with integer keys" -Skip:(-not $script:isV2Enabled) { + $dict = @{ 1 = "one"; 2 = "two" } + $json = $dict | ConvertTo-Json -Compress + $json | Should -Match '"1":\s*"one"' + $json | Should -Match '"2":\s*"two"' + } + + It "V2: Should serialize Exception.Data with non-string keys" -Skip:(-not $script:isV2Enabled) { + $ex = [System.Exception]::new("test") + $ex.Data.Add(1, "value1") + $ex.Data.Add("key", "value2") + { $ex | ConvertTo-Json -Depth 1 } | Should -Not -Throw + } + } + + Context "JsonIgnoreAttribute and HiddenAttribute" { + It "V2: Should not serialize hidden properties in PowerShell class" -Skip:(-not $script:isV2Enabled) { + class TestHiddenClass { + [string] $Visible + hidden [string] $Hidden + } + $obj = [TestHiddenClass]::new() + $obj.Visible = "yes" + $obj.Hidden = "no" + $json = $obj | ConvertTo-Json -Compress + $json | Should -Match 'Visible' + $json | Should -Not -Match 'Hidden' + } + } + + Context "Special types" { + It "V2: Should serialize Uri correctly" -Skip:(-not $script:isV2Enabled) { + $uri = [uri]"https://example.com/path" + $json = $uri | ConvertTo-Json -Compress + $json | Should -BeExactly '"https://example.com/path"' + } + + It "V2: Should serialize Guid correctly" -Skip:(-not $script:isV2Enabled) { + $guid = [guid]"12345678-1234-1234-1234-123456789abc" + $json = ConvertTo-Json -InputObject $guid -Compress + $json | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' + } + + It "V2: Should serialize BigInteger correctly" -Skip:(-not $script:isV2Enabled) { + $big = [System.Numerics.BigInteger]::Parse("123456789012345678901234567890") + $json = ConvertTo-Json -InputObject $big -Compress + $json | Should -BeExactly '123456789012345678901234567890' + } + + It "V2: Should serialize enums as numbers by default" -Skip:(-not $script:isV2Enabled) { + $json = [System.DayOfWeek]::Monday | ConvertTo-Json + $json | Should -BeExactly '1' + } + + It "V2: Should serialize enums as strings with -EnumsAsStrings" -Skip:(-not $script:isV2Enabled) { + $json = [System.DayOfWeek]::Monday | ConvertTo-Json -EnumsAsStrings + $json | Should -BeExactly '"Monday"' + } + } + + Context "Null handling" { + It "V2: Should serialize null correctly" -Skip:(-not $script:isV2Enabled) { + $null | ConvertTo-Json | Should -BeExactly 'null' + } + + It "V2: Should serialize DBNull as null" -Skip:(-not $script:isV2Enabled) { + [System.DBNull]::Value | ConvertTo-Json | Should -BeExactly 'null' + } + + It "V2: Should serialize NullString as null" -Skip:(-not $script:isV2Enabled) { + [NullString]::Value | ConvertTo-Json | Should -BeExactly 'null' + } + + It "V2: Should handle ETS properties on DBNull" -Skip:(-not $script:isV2Enabled) { + try { + $p = Add-Member -InputObject ([System.DBNull]::Value) -MemberType NoteProperty -Name testprop -Value 'testvalue' -PassThru + $json = $p | ConvertTo-Json -Compress + $json | Should -Match '"value":null' + $json | Should -Match '"testprop":"testvalue"' + } + finally { + $p.psobject.Properties.Remove('testprop') + } + } + } + + Context "Collections" { + It "V2: Should serialize arrays correctly" -Skip:(-not $script:isV2Enabled) { + $arr = @(1, 2, 3) + $json = $arr | ConvertTo-Json -Compress + $json | Should -BeExactly '[1,2,3]' + } + + It "V2: Should serialize hashtable correctly" -Skip:(-not $script:isV2Enabled) { + $hash = [ordered]@{ a = 1; b = 2 } + $json = $hash | ConvertTo-Json -Compress + $json | Should -BeExactly '{"a":1,"b":2}' + } + + It "V2: Should serialize nested objects correctly" -Skip:(-not $script:isV2Enabled) { + $obj = [pscustomobject]@{ + name = "test" + child = [pscustomobject]@{ + value = 42 + } + } + $json = $obj | ConvertTo-Json -Compress + $json | Should -BeExactly '{"name":"test","child":{"value":42}}' + } + } + + Context "EscapeHandling" { + It "V2: Should not escape by default" -Skip:(-not $script:isV2Enabled) { + $json = @{ text = "<>&" } | ConvertTo-Json -Compress + $json | Should -BeExactly '{"text":"<>&"}' + } + + It "V2: Should escape HTML with -EscapeHandling EscapeHtml" -Skip:(-not $script:isV2Enabled) { + $json = @{ text = "<>&" } | ConvertTo-Json -Compress -EscapeHandling EscapeHtml + $json | Should -Match '\\u003C' + $json | Should -Match '\\u003E' + $json | Should -Match '\\u0026' + } + + It "V2: Should escape non-ASCII with -EscapeHandling EscapeNonAscii" -Skip:(-not $script:isV2Enabled) { + $json = @{ text = "日本語" } | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii + $json | Should -Match '\\u' + } + } + + Context "Backward compatibility" { + It "V2: Should still support Newtonsoft JObject" -Skip:(-not $script:isV2Enabled) { + $jobj = New-Object Newtonsoft.Json.Linq.JObject + $jobj.Add("key", [Newtonsoft.Json.Linq.JToken]::FromObject("value")) + $json = @{ data = $jobj } | ConvertTo-Json -Compress -Depth 2 + $json | Should -Match '"key":\s*"value"' + } + + It "V2: Depth parameter should work" -Skip:(-not $script:isV2Enabled) { + $obj = @{ a = @{ b = 1 } } + $json = $obj | ConvertTo-Json -Depth 2 -Compress + $json | Should -BeExactly '{"a":{"b":1}}' + } + } +} From 489617d82fcc16df3a4349193deff3bd26fe667b Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 14:52:00 +0900 Subject: [PATCH 02/42] Refactor PowerShellJsonWriter to use iterative approach instead of recursion --- .../WebCmdlet/SystemTextJsonSerializer.cs | 263 ++++++++++++------ 1 file changed, 180 insertions(+), 83 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs index 689ab983143..30920117076 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs @@ -62,7 +62,7 @@ internal static class SystemTextJsonSerializer context.EnumsAsStrings, context.Cmdlet, context.CancellationToken); - serializer.WriteValue(writer, objectToProcess, currentDepth: 0); + serializer.WriteValue(writer, objectToProcess); } return Encoding.UTF8.GetString(stream.ToArray()); @@ -104,18 +104,17 @@ internal static void WriteMaxDepthWarning(int maxDepth, PSCmdlet? cmdlet) } /// - /// Writes PowerShell objects to JSON with manual depth tracking. + /// Writes PowerShell objects to JSON using an iterative (non-recursive) approach. /// /// /// - /// This class writes JSON directly to Utf8JsonWriter instead of using - /// JsonSerializer.Serialize() with custom converters. This approach avoids - /// issues with System.Text.Json's internal depth tracking when converters - /// call Serialize() recursively. + /// This class uses an explicit stack instead of recursion to avoid stack overflow + /// when serializing deeply nested objects. This allows safe handling of any depth + /// up to the configured maximum without risking call stack exhaustion. /// /// /// Key features: - /// - Manual depth tracking with graceful degradation (string conversion) on depth exceeded + /// - Iterative depth tracking with graceful degradation (string conversion) on depth exceeded /// - Support for PSObject with extended/adapted properties /// - Support for non-string dictionary keys (converted via ToString()) /// - Respects JsonIgnoreAttribute and PowerShell's HiddenAttribute @@ -141,14 +140,100 @@ public PowerShellJsonWriter( _cancellationToken = cancellationToken; } + #region Stack-based Task Types + + /// + /// Represents the type of task to be processed. + /// + private enum TaskType + { + /// Write a value (may be primitive or complex). + WriteValue, + /// Write end of JSON object. + EndObject, + /// Write end of JSON array. + EndArray, + } + + /// + /// Represents a task on the processing stack. + /// + private readonly struct WriteTask + { + public readonly TaskType Type; + public readonly string? PropertyName; + public readonly object? Value; + public readonly PSObject? PSObject; + public readonly int Depth; + + private WriteTask(TaskType type, string? propertyName, object? value, PSObject? pso, int depth) + { + Type = type; + PropertyName = propertyName; + Value = value; + PSObject = pso; + Depth = depth; + } + + public static WriteTask ForValue(object? value, int depth, string? propertyName = null) + => new(TaskType.WriteValue, propertyName, value, value as PSObject, depth); + + public static WriteTask ForValueWithPSObject(object? value, PSObject? pso, int depth, string? propertyName = null) + => new(TaskType.WriteValue, propertyName, value, pso, depth); + + public static WriteTask ForEndObject() => new(TaskType.EndObject, null, null, null, 0); + + public static WriteTask ForEndArray() => new(TaskType.EndArray, null, null, null, 0); + } + + #endregion + #region Main Entry Point /// - /// Writes a value to JSON, handling all PowerShell-specific types. + /// Writes a value to JSON using an iterative approach. /// - internal void WriteValue(Utf8JsonWriter writer, object? value, int currentDepth) + internal void WriteValue(Utf8JsonWriter writer, object? value) { - _cancellationToken.ThrowIfCancellationRequested(); + var stack = new Stack(); + stack.Push(WriteTask.ForValue(value, 0)); + + while (stack.Count > 0) + { + _cancellationToken.ThrowIfCancellationRequested(); + + var task = stack.Pop(); + + switch (task.Type) + { + case TaskType.EndObject: + writer.WriteEndObject(); + break; + + case TaskType.EndArray: + writer.WriteEndArray(); + break; + + case TaskType.WriteValue: + ProcessWriteValue(writer, stack, task); + break; + } + } + } + + /// + /// Processes a WriteValue task. + /// + private void ProcessWriteValue(Utf8JsonWriter writer, Stack stack, WriteTask task) + { + // Write property name if present + if (task.PropertyName is not null) + { + writer.WritePropertyName(task.PropertyName); + } + + object? value = task.Value; + int currentDepth = task.Depth; // Handle null if (value is null || LanguagePrimitives.IsNull(value)) @@ -158,11 +243,11 @@ internal void WriteValue(Utf8JsonWriter writer, object? value, int currentDepth) } // Unwrap PSObject and get base object - PSObject? pso = value as PSObject; + PSObject? pso = task.PSObject ?? (value as PSObject); object baseObject = pso?.BaseObject ?? value; // Handle special null-like values (NullString, DBNull) - if (TryWriteNullLike(writer, baseObject, pso, currentDepth)) + if (TryWriteNullLike(writer, stack, baseObject, pso, currentDepth)) { return; } @@ -187,8 +272,8 @@ internal void WriteValue(Utf8JsonWriter writer, object? value, int currentDepth) return; } - // Handle complex types - WriteComplexValue(writer, baseObject, pso, currentDepth); + // Handle complex types by pushing tasks onto the stack + ProcessComplexValue(writer, stack, baseObject, pso, currentDepth); } #endregion @@ -305,108 +390,103 @@ private void WriteEnum(Utf8JsonWriter writer, object value) #region Complex Types /// - /// Writes a complex value (dictionary, array, or object). + /// Processes a complex value by pushing appropriate tasks onto the stack. /// - private void WriteComplexValue(Utf8JsonWriter writer, object value, PSObject? pso, int currentDepth) + private static void ProcessComplexValue(Utf8JsonWriter writer, Stack stack, object value, PSObject? pso, int currentDepth) { // Handle Newtonsoft.Json JObject (for backward compatibility) if (value is Newtonsoft.Json.Linq.JObject jObject) { - WriteDictionary(writer, jObject.ToObject>()!, currentDepth, pso: null); + ProcessDictionary(writer, stack, jObject.ToObject>()!, null, currentDepth); return; } // Handle dictionaries if (value is IDictionary dict) { - WriteDictionary(writer, dict, currentDepth, pso); + ProcessDictionary(writer, stack, dict, pso, currentDepth); return; } // Handle enumerables (arrays, lists, etc.) if (value is IEnumerable enumerable) { - WriteArray(writer, enumerable, currentDepth); + ProcessArray(writer, stack, enumerable, currentDepth); return; } // Handle custom objects (classes, structs) - WriteCustomObject(writer, value, pso, currentDepth); + ProcessCustomObject(writer, stack, value, pso, currentDepth); } /// - /// Writes a dictionary as a JSON object. + /// Processes a dictionary by pushing tasks for each entry onto the stack. /// - /// - /// Non-string keys are converted to strings via ToString(). - /// This enables serialization of dictionaries like Exception.Data. - /// - private void WriteDictionary(Utf8JsonWriter writer, IDictionary dict, int currentDepth, PSObject? pso) + private static void ProcessDictionary(Utf8JsonWriter writer, Stack stack, IDictionary dict, PSObject? pso, int currentDepth) { writer.WriteStartObject(); + // Collect entries to push in reverse order (stack is LIFO) + var entries = new List<(string Key, object? Value)>(); + foreach (DictionaryEntry entry in dict) { string key = entry.Key?.ToString() ?? string.Empty; - writer.WritePropertyName(key); - WriteValue(writer, entry.Value, currentDepth + 1); + entries.Add((key, entry.Value)); } + // Add extended properties if present if (pso is not null) { - AppendExtendedProperties(writer, pso, dict, currentDepth, isCustomObject: false); + CollectExtendedProperties(entries, pso, dict, currentDepth, isCustomObject: false); } - writer.WriteEndObject(); + // Push EndObject first (will be processed last) + stack.Push(WriteTask.ForEndObject()); + + // Push entries in reverse order + for (int i = entries.Count - 1; i >= 0; i--) + { + stack.Push(WriteTask.ForValue(entries[i].Value, currentDepth + 1, entries[i].Key)); + } } /// - /// Writes an enumerable as a JSON array. + /// Processes an array by pushing tasks for each element onto the stack. /// - private void WriteArray(Utf8JsonWriter writer, IEnumerable enumerable, int currentDepth) + private static void ProcessArray(Utf8JsonWriter writer, Stack stack, IEnumerable enumerable, int currentDepth) { writer.WriteStartArray(); + // Collect items to push in reverse order + var items = new List(); foreach (object? item in enumerable) { - WriteValue(writer, item, currentDepth + 1); + items.Add(item); } - writer.WriteEndArray(); + // Push EndArray first (will be processed last) + stack.Push(WriteTask.ForEndArray()); + + // Push items in reverse order + for (int i = items.Count - 1; i >= 0; i--) + { + stack.Push(WriteTask.ForValue(items[i], currentDepth + 1)); + } } /// - /// Writes a custom object (class or struct) as a JSON object. + /// Processes a custom object by pushing tasks for each property onto the stack. /// - private void WriteCustomObject(Utf8JsonWriter writer, object value, PSObject? pso, int currentDepth) + private static void ProcessCustomObject(Utf8JsonWriter writer, Stack stack, object value, PSObject? pso, int currentDepth) { writer.WriteStartObject(); Type type = value.GetType(); + var entries = new List<(string Key, object? Value)>(); var writtenProperties = new HashSet(StringComparer.OrdinalIgnoreCase); - // Write public fields - WriteFields(writer, value, type, currentDepth, writtenProperties); - - // Write public properties - WriteProperties(writer, value, type, currentDepth, writtenProperties); - - // Add extended properties from PSObject - if (pso is not null) - { - AppendExtendedProperties(writer, pso, writtenProperties, currentDepth, isCustomObject: true); - } - - writer.WriteEndObject(); - } - - private void WriteFields( - Utf8JsonWriter writer, - object value, - Type type, - int currentDepth, - HashSet writtenProperties) - { + // Collect public fields foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Instance)) { if (ShouldSkipMember(field)) @@ -415,19 +495,11 @@ private void WriteFields( } object? fieldValue = TryGetFieldValue(field, value); - writer.WritePropertyName(field.Name); - WriteValue(writer, fieldValue, currentDepth + 1); + entries.Add((field.Name, fieldValue)); writtenProperties.Add(field.Name); } - } - private void WriteProperties( - Utf8JsonWriter writer, - object value, - Type type, - int currentDepth, - HashSet writtenProperties) - { + // Collect public properties foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { if (ShouldSkipMember(property)) @@ -442,10 +514,24 @@ private void WriteProperties( } object? propertyValue = TryGetPropertyValue(getter, value); - writer.WritePropertyName(property.Name); - WriteValue(writer, propertyValue, currentDepth + 1); + entries.Add((property.Name, propertyValue)); writtenProperties.Add(property.Name); } + + // Add extended properties from PSObject + if (pso is not null) + { + CollectExtendedProperties(entries, pso, writtenProperties, currentDepth, isCustomObject: true); + } + + // Push EndObject first (will be processed last) + stack.Push(WriteTask.ForEndObject()); + + // Push entries in reverse order + for (int i = entries.Count - 1; i >= 0; i--) + { + stack.Push(WriteTask.ForValue(entries[i].Value, currentDepth + 1, entries[i].Key)); + } } #endregion @@ -455,7 +541,7 @@ private void WriteProperties( /// /// Handles NullString and DBNull values, which may have extended properties. /// - private bool TryWriteNullLike(Utf8JsonWriter writer, object value, PSObject? pso, int currentDepth) + private static bool TryWriteNullLike(Utf8JsonWriter writer, Stack stack, object value, PSObject? pso, int currentDepth) { if (value != System.Management.Automation.Language.NullString.Value && value != DBNull.Value) { @@ -464,7 +550,7 @@ private bool TryWriteNullLike(Utf8JsonWriter writer, object value, PSObject? pso if (pso is not null && HasExtendedProperties(pso)) { - WriteObjectWithNullValue(writer, pso, currentDepth); + ProcessObjectWithNullValue(writer, stack, pso, currentDepth); } else { @@ -475,22 +561,34 @@ private bool TryWriteNullLike(Utf8JsonWriter writer, object value, PSObject? pso } /// - /// Writes an object with a null base value but with extended properties. + /// Processes an object with a null base value but with extended properties. /// - private void WriteObjectWithNullValue(Utf8JsonWriter writer, PSObject pso, int currentDepth) + private static void ProcessObjectWithNullValue(Utf8JsonWriter writer, Stack stack, PSObject pso, int currentDepth) { writer.WriteStartObject(); - writer.WritePropertyName("value"); - writer.WriteNullValue(); - AppendExtendedProperties(writer, pso, writtenKeys: null, currentDepth, isCustomObject: false); - writer.WriteEndObject(); + + var entries = new List<(string Key, object? Value)> + { + ("value", null) + }; + + CollectExtendedProperties(entries, pso, writtenKeys: null, currentDepth, isCustomObject: false); + + // Push EndObject first + stack.Push(WriteTask.ForEndObject()); + + // Push entries in reverse order + for (int i = entries.Count - 1; i >= 0; i--) + { + stack.Push(WriteTask.ForValue(entries[i].Value, currentDepth + 1, entries[i].Key)); + } } /// - /// Appends extended (and optionally adapted) properties from a PSObject. + /// Collects extended (and optionally adapted) properties from a PSObject. /// - private void AppendExtendedProperties( - Utf8JsonWriter writer, + private static void CollectExtendedProperties( + List<(string Key, object? Value)> entries, PSObject pso, object? writtenKeys, int currentDepth, @@ -518,8 +616,7 @@ private void AppendExtendedProperties( } object? propValue = TryGetPSPropertyValue(prop); - writer.WritePropertyName(prop.Name); - WriteValue(writer, propValue, currentDepth + 1); + entries.Add((prop.Name, propValue)); } } From 086ee557a37d50d4282ef0ca8f58953954d10ca6 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 15:49:49 +0900 Subject: [PATCH 03/42] Remove Depth upper limit and allow -1 for unlimited when PSJsonSerializerV2 is enabled --- .../utility/WebCmdlet/ConvertToJsonCommand.cs | 55 +++++++++++++------ .../resources/WebCmdletStrings.resx | 3 + ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 17 ++++-- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index ad6b098cf7c..f6fe8b14e3f 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -22,7 +22,6 @@ public class ConvertToJsonCommand : PSCmdlet, IDisposable private const int DefaultDepth = 2; private const int DefaultDepthV2 = 64; private const int DepthAllowed = 100; - private const int DepthAllowedV2 = 1000; /// /// Gets or sets the InputObject property. @@ -41,14 +40,14 @@ public class ConvertToJsonCommand : PSCmdlet, IDisposable /// Otherwise: default is 2, max is 100. /// [Parameter] - [ValidateRange(0, DepthAllowedV2)] public int Depth { get { if (_depth.HasValue) { - return _depth.Value; + // -1 means unlimited depth + return _depth.Value == -1 ? int.MaxValue : _depth.Value; } return ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2) @@ -135,22 +134,44 @@ protected override void ProcessRecord() /// protected override void BeginProcessing() { - // When PSJsonSerializerV2 is not enabled, enforce the legacy max depth limit - if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2)) + if (_depth.HasValue) { - if (_depth.HasValue && _depth.Value > DepthAllowed) + bool isV2Enabled = ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2); + + if (isV2Enabled) + { + // V2: -1 is allowed (unlimited), but -2 or less is an error + if (_depth.Value < -1) + { + var errorRecord = new ErrorRecord( + new ArgumentException( + string.Format( + System.Globalization.CultureInfo.CurrentCulture, + WebCmdletStrings.JsonDepthMustBeNonNegative, + _depth.Value)), + "DepthMustBeNonNegative", + ErrorCategory.InvalidArgument, + _depth.Value); + ThrowTerminatingError(errorRecord); + } + } + else { - var errorRecord = new ErrorRecord( - new ArgumentException( - string.Format( - System.Globalization.CultureInfo.CurrentCulture, - WebCmdletStrings.JsonDepthExceedsLimit, - _depth.Value, - DepthAllowed)), - "DepthExceedsLimit", - ErrorCategory.InvalidArgument, - _depth.Value); - ThrowTerminatingError(errorRecord); + // Legacy: only 0 to DepthAllowed is valid + if (_depth.Value < 0 || _depth.Value > DepthAllowed) + { + var errorRecord = new ErrorRecord( + new ArgumentException( + string.Format( + System.Globalization.CultureInfo.CurrentCulture, + WebCmdletStrings.JsonDepthExceedsLimit, + _depth.Value, + DepthAllowed)), + "DepthExceedsLimit", + ErrorCategory.InvalidArgument, + _depth.Value); + ThrowTerminatingError(errorRecord); + } } } } diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index eeb7e1e8b29..e3dd4090bc5 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -249,6 +249,9 @@ The value {0} is not valid for the Depth parameter. The valid range is 0 to {1}. To use higher values, enable the PSJsonSerializerV2 experimental feature. + + The value {0} is not valid for the Depth parameter. The value must be -1 (unlimited) or a non-negative number. + The WebSession properties were changed between requests forcing all HTTP connections in the session to be recreated. diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index 8f729a143fd..0a500015485 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -20,12 +20,21 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { $json | Should -Match '"level":63' } - It "V2: Depth up to 1000 should be allowed" -Skip:(-not $script:isV2Enabled) { - { ConvertTo-Json -InputObject @{a=1} -Depth 1000 } | Should -Not -Throw + It "V2: Large depth values should be allowed" -Skip:(-not $script:isV2Enabled) { + { ConvertTo-Json -InputObject @{a=1} -Depth 10000 } | Should -Not -Throw } - It "V2: Depth over 1000 should throw" -Skip:(-not $script:isV2Enabled) { - { ConvertTo-Json -InputObject @{a=1} -Depth 1001 } | Should -Throw + It "V2: Depth -1 should work as unlimited" -Skip:(-not $script:isV2Enabled) { + $obj = @{ level = 0 } + for ($i = 1; $i -lt 200; $i++) { + $obj = @{ level = $i; child = $obj } + } + $json = $obj | ConvertTo-Json -Depth -1 -Compress -WarningAction SilentlyContinue + $json | Should -Match '"level":199' + } + + It "V2: Negative depth other than -1 should throw" -Skip:(-not $script:isV2Enabled) { + { ConvertTo-Json -InputObject @{a=1} -Depth -2 } | Should -Throw } It "Legacy: Depth over 100 should throw when V2 is disabled" -Skip:$script:isV2Enabled { From b8b0c6bae2d2dbbadb24028ac3ee20caea1a1179 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 16:28:22 +0900 Subject: [PATCH 04/42] Add backward compatibility tests that run in both V2 enabled and disabled states --- ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index 0a500015485..75d6b7e9b5d 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -192,17 +192,32 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } Context "Backward compatibility" { - It "V2: Should still support Newtonsoft JObject" -Skip:(-not $script:isV2Enabled) { + It "Should still support Newtonsoft JObject" { $jobj = New-Object Newtonsoft.Json.Linq.JObject $jobj.Add("key", [Newtonsoft.Json.Linq.JToken]::FromObject("value")) $json = @{ data = $jobj } | ConvertTo-Json -Compress -Depth 2 $json | Should -Match '"key":\s*"value"' } - It "V2: Depth parameter should work" -Skip:(-not $script:isV2Enabled) { + It "Depth parameter should work" { $obj = @{ a = @{ b = 1 } } $json = $obj | ConvertTo-Json -Depth 2 -Compress $json | Should -BeExactly '{"a":{"b":1}}' } + + It "AsArray parameter should work" { + $json = @{a=1} | ConvertTo-Json -AsArray -Compress + $json | Should -BeExactly '[{"a":1}]' + } + + It "Multiple objects from pipeline should be serialized as array" { + $json = 1, 2, 3 | ConvertTo-Json -Compress + $json | Should -BeExactly '[1,2,3]' + } + + It "Multiple objects from pipeline with AsArray should work" { + $json = @{a=1}, @{b=2} | ConvertTo-Json -AsArray -Compress + $json | Should -Match '^\[.*\]$' + } } } From ed2ac16b4047d4512223293771eceeb89da4f0a7 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 16:47:15 +0900 Subject: [PATCH 05/42] Fix description and doc comment to reflect no upper depth limit --- .../commands/utility/WebCmdlet/ConvertToJsonCommand.cs | 2 +- .../engine/ExperimentalFeature/ExperimentalFeature.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index f6fe8b14e3f..68ba92971aa 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -36,7 +36,7 @@ public class ConvertToJsonCommand : PSCmdlet, IDisposable /// /// Gets or sets the Depth property. - /// When PSJsonSerializerV2 is enabled: default is 64, max is 1000. + /// When PSJsonSerializerV2 is enabled: default is 64, no upper limit (-1 for unlimited). /// Otherwise: default is 2, max is 100. /// [Parameter] diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 40ea668215e..95675a76889 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -114,7 +114,7 @@ static ExperimentalFeature() ), new ExperimentalFeature( name: PSJsonSerializerV2, - description: "Use System.Text.Json with improved defaults for ConvertTo-Json: Depth default 64 (was 2), limit 1000 (was 100)." + description: "Use System.Text.Json with improved defaults for ConvertTo-Json: Depth default 64 (was 2), no upper limit (-1 for unlimited)." ), new ExperimentalFeature( name: PSProfileDSCResource, From 264335eaccbc0acebb5a18f809db7e5316deeb90 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 17:06:28 +0900 Subject: [PATCH 06/42] Fix CodeFactor style issues in SystemTextJsonSerializer.cs --- .../commands/utility/WebCmdlet/SystemTextJsonSerializer.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs index 30920117076..6200ed70cc2 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs @@ -118,7 +118,7 @@ internal static void WriteMaxDepthWarning(int maxDepth, PSCmdlet? cmdlet) /// - Support for PSObject with extended/adapted properties /// - Support for non-string dictionary keys (converted via ToString()) /// - Respects JsonIgnoreAttribute and PowerShell's HiddenAttribute - /// - Special handling for Int64/UInt64 enums (JavaScript precision issue) + /// - Special handling for Int64/UInt64 enums (JavaScript precision issue). /// /// internal sealed class PowerShellJsonWriter @@ -149,8 +149,10 @@ private enum TaskType { /// Write a value (may be primitive or complex). WriteValue, + /// Write end of JSON object. EndObject, + /// Write end of JSON array. EndArray, } @@ -569,7 +571,7 @@ private static void ProcessObjectWithNullValue(Utf8JsonWriter writer, Stack { - ("value", null) + ("value", null), }; CollectExtendedProperties(entries, pso, writtenKeys: null, currentDepth, isCustomObject: false); From 159b76387710892690e488d345b5d995417e379f Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 18:12:04 +0900 Subject: [PATCH 07/42] Use $EnabledExperimentalFeatures for V2 feature detection in tests --- .../ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index 75d6b7e9b5d..94638609daa 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -2,9 +2,7 @@ # Licensed under the MIT License. BeforeDiscovery { - # Check if V2 is enabled using Get-ExperimentalFeature - $script:v2Feature = Get-ExperimentalFeature -Name PSJsonSerializerV2 -ErrorAction SilentlyContinue - $script:isV2Enabled = $script:v2Feature -and $script:v2Feature.Enabled + $script:isV2Enabled = $EnabledExperimentalFeatures.Contains('PSJsonSerializerV2') } Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { @@ -60,7 +58,7 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } } - Context "Non-string dictionary keys (Issue #5749)" { + Context "Non-string dictionary keys" { It "V2: Should serialize dictionary with integer keys" -Skip:(-not $script:isV2Enabled) { $dict = @{ 1 = "one"; 2 = "two" } $json = $dict | ConvertTo-Json -Compress From e7dfff962fb62403e2306d5cc4a130e88d6a827e Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 17 Dec 2025 23:18:24 +0900 Subject: [PATCH 08/42] Refactor ConvertToJsonCommandV2 to use standard JsonSerializer approach Based on iSazonov's feedback and testing, this commit refactors the V2 implementation to use the standard JsonSerializer.Serialize() with custom JsonConverter classes instead of the custom PowerShellJsonWriter. Key changes: - Removed PowerShellJsonWriter class (~500 lines of iterative serialization) - Implemented JsonSerializer.Serialize() with custom JsonConverters: - JsonConverterPSObject: Handles PSObject serialization - JsonConverterInt64Enum: Converts long/ulong enums to strings - JsonConverterNullString: Serializes NullString as null - JsonConverterDBNull: Serializes DBNull as null - Updated ConvertToJsonCommandV2: - Changed ValidateRange from (1, int.MaxValue) to (0, 1000) - Changed default Depth from int.MaxValue to 64 - Updated XML documentation to reflect System.Text.Json limitations - Deleted separate SystemTextJsonSerializer.cs file (integrated into ConvertToJsonCommand.cs) - Updated .gitignore to exclude test files Rationale: Testing revealed that Utf8JsonWriter has a hardcoded MaxDepth limit of 1000, making the custom iterative implementation unnecessary. Stack overflow is not a practical concern with this limit, as iSazonov correctly identified. Net result: ~400 lines of code removed while maintaining functionality. Status: - Build: Success - Basic tests: Passing - Full test suite: 4 passed, 9 failed (edge cases need fixing) - Known issues: NullString/DBNull serialization, BigInteger support This is a work-in-progress commit to preserve the refactoring effort. Further fixes needed for full test suite compliance. --- .gitignore | 5 + .../utility/WebCmdlet/ConvertToJsonCommand.cs | 451 +++++++++-- .../commands/utility/WebCmdlet/JsonObject.cs | 6 - .../WebCmdlet/SystemTextJsonSerializer.cs | 723 ------------------ ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 82 +- 5 files changed, 456 insertions(+), 811 deletions(-) delete mode 100644 src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs diff --git a/.gitignore b/.gitignore index f115e61e22d..bf5c6050677 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,8 @@ tmp/* # Ignore CTRF report files crtf/* +# Work in progress files +work_progress.md +*_test*.ps1 +*_test*.json +*_test*.txt diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index 68ba92971aa..d1cfff1adaf 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -2,27 +2,40 @@ // Licensed under the MIT License. using System; +using System.Collections; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Management.Automation; using System.Management.Automation.Internal; +using System.Numerics; +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Unicode; using System.Threading; using Newtonsoft.Json; +using NewtonsoftStringEscapeHandling = Newtonsoft.Json.StringEscapeHandling; +using StjJsonIgnoreAttribute = System.Text.Json.Serialization.JsonIgnoreAttribute; + namespace Microsoft.PowerShell.Commands { /// /// The ConvertTo-Json command. /// This command converts an object to a Json string representation. /// + /// + /// This class is hidden when PSJsonSerializerV2 experimental feature is enabled. + /// + [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Hide)] [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)] [OutputType(typeof(string))] public class ConvertToJsonCommand : PSCmdlet, IDisposable { - private const int DefaultDepth = 2; - private const int DefaultDepthV2 = 64; - private const int DepthAllowed = 100; - /// /// Gets or sets the InputObject property. /// @@ -30,35 +43,19 @@ public class ConvertToJsonCommand : PSCmdlet, IDisposable [AllowNull] public object InputObject { get; set; } - private int? _depth; + private int _depth = 2; private readonly CancellationTokenSource _cancellationSource = new(); /// /// Gets or sets the Depth property. - /// When PSJsonSerializerV2 is enabled: default is 64, no upper limit (-1 for unlimited). - /// Otherwise: default is 2, max is 100. /// [Parameter] + [ValidateRange(0, 100)] public int Depth { - get - { - if (_depth.HasValue) - { - // -1 means unlimited depth - return _depth.Value == -1 ? int.MaxValue : _depth.Value; - } - - return ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2) - ? DefaultDepthV2 - : DefaultDepth; - } - - set - { - _depth = value; - } + get { return _depth; } + set { _depth = value; } } /// @@ -130,52 +127,141 @@ protected override void ProcessRecord() } /// - /// Validate parameters and prepare for processing. + /// Do the conversion to json and write output. /// - protected override void BeginProcessing() + protected override void EndProcessing() { - if (_depth.HasValue) + if (_inputObjects.Count > 0) { - bool isV2Enabled = ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2); + object objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (_inputObjects.ToArray() as object) : _inputObjects[0]; - if (isV2Enabled) - { - // V2: -1 is allowed (unlimited), but -2 or less is an error - if (_depth.Value < -1) - { - var errorRecord = new ErrorRecord( - new ArgumentException( - string.Format( - System.Globalization.CultureInfo.CurrentCulture, - WebCmdletStrings.JsonDepthMustBeNonNegative, - _depth.Value)), - "DepthMustBeNonNegative", - ErrorCategory.InvalidArgument, - _depth.Value); - ThrowTerminatingError(errorRecord); - } - } - else + var context = new JsonObject.ConvertToJsonContext( + Depth, + EnumsAsStrings.IsPresent, + Compress.IsPresent, + EscapeHandling, + targetCmdlet: this, + _cancellationSource.Token); + + // null is returned only if the pipeline is stopping (e.g. ctrl+c is signaled). + // in that case, we shouldn't write the null to the output pipe. + string output = JsonObject.ConvertToJson(objectToProcess, in context); + if (output != null) { - // Legacy: only 0 to DepthAllowed is valid - if (_depth.Value < 0 || _depth.Value > DepthAllowed) - { - var errorRecord = new ErrorRecord( - new ArgumentException( - string.Format( - System.Globalization.CultureInfo.CurrentCulture, - WebCmdletStrings.JsonDepthExceedsLimit, - _depth.Value, - DepthAllowed)), - "DepthExceedsLimit", - ErrorCategory.InvalidArgument, - _depth.Value); - ThrowTerminatingError(errorRecord); - } + WriteObject(output); } } } + /// + /// Process the Ctrl+C signal. + /// + protected override void StopProcessing() + { + _cancellationSource.Cancel(); + } + } + +#nullable enable + /// + /// The ConvertTo-Json command (V2 - System.Text.Json implementation). + /// This command converts an object to a Json string representation. + /// + /// + /// This class is shown when PSJsonSerializerV2 experimental feature is enabled. + /// V2 uses System.Text.Json with circular reference detection and unlimited depth by default. + /// + [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Show)] + [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)] + [OutputType(typeof(string))] + public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable + { + /// + /// Gets or sets the InputObject property. + /// + [Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true)] + [AllowNull] + public object? InputObject { get; set; } + + private readonly CancellationTokenSource _cancellationSource = new(); + + /// + /// Gets or sets the Depth property. + /// Default is 64. Maximum allowed depth is 1000 due to System.Text.Json limitations. + /// Use 0 to serialize only top-level properties. + /// + [Parameter] + [ValidateRange(0, 1000)] + public int Depth { get; set; } = 64; + + /// + /// Gets or sets the Compress property. + /// If the Compress property is set to be true, the Json string will + /// be output in the compressed way. Otherwise, the Json string will + /// be output with indentations. + /// + [Parameter] + public SwitchParameter Compress { get; set; } + + /// + /// Gets or sets the EnumsAsStrings property. + /// If the EnumsAsStrings property is set to true, enum values will + /// be converted to their string equivalent. Otherwise, enum values + /// will be converted to their numeric equivalent. + /// + [Parameter] + public SwitchParameter EnumsAsStrings { get; set; } + + /// + /// Gets or sets the AsArray property. + /// If the AsArray property is set to be true, the result JSON string will + /// be returned with surrounding '[', ']' chars. Otherwise, + /// the array symbols will occur only if there is more than one input object. + /// + [Parameter] + public SwitchParameter AsArray { get; set; } + + /// + /// Specifies how strings are escaped when writing JSON text. + /// If the EscapeHandling property is set to EscapeHtml, the result JSON string will + /// be returned with HTML (<, >, &, ', ") and control characters (e.g. newline) are escaped. + /// + [Parameter] + public NewtonsoftStringEscapeHandling EscapeHandling { get; set; } = NewtonsoftStringEscapeHandling.Default; + + /// + /// IDisposable implementation, dispose of any disposable resources created by the cmdlet. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Implementation of IDisposable for both manual Dispose() and finalizer-called disposal of resources. + /// + /// + /// Specified as true when Dispose() was called, false if this is called from the finalizer. + /// + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _cancellationSource.Dispose(); + } + } + + private readonly List _inputObjects = new(); + + /// + /// Caching the input objects for the command. + /// + protected override void ProcessRecord() + { + _inputObjects.Add(InputObject); + } + /// /// Do the conversion to json and write output. /// @@ -183,20 +269,20 @@ protected override void EndProcessing() { if (_inputObjects.Count > 0) { - object objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (_inputObjects.ToArray() as object) : _inputObjects[0]; + object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (_inputObjects.ToArray() as object) : _inputObjects[0]; - var context = new JsonObject.ConvertToJsonContext( + string? output = SystemTextJsonSerializer.ConvertToJson( + objectToProcess, Depth, EnumsAsStrings.IsPresent, Compress.IsPresent, EscapeHandling, - targetCmdlet: this, + this, _cancellationSource.Token); // null is returned only if the pipeline is stopping (e.g. ctrl+c is signaled). // in that case, we shouldn't write the null to the output pipe. - string output = JsonObject.ConvertToJson(objectToProcess, in context); - if (output != null) + if (output is not null) { WriteObject(output); } @@ -211,4 +297,237 @@ protected override void StopProcessing() _cancellationSource.Cancel(); } } + + /// + /// Provides JSON serialization using System.Text.Json with PowerShell-specific handling. + /// + internal static class SystemTextJsonSerializer + { + /// + /// Convert an object to JSON string using System.Text.Json. + /// + public static string? ConvertToJson( + object? objectToProcess, + int maxDepth, + bool enumsAsStrings, + bool compressOutput, + NewtonsoftStringEscapeHandling stringEscapeHandling, + PSCmdlet? cmdlet, + CancellationToken cancellationToken) + { + if (objectToProcess is null) + { + return "null"; + } + + try + { + var options = new JsonSerializerOptions() + { + WriteIndented = !compressOutput, + MaxDepth = maxDepth, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Encoder = GetEncoder(stringEscapeHandling), + }; + + if (enumsAsStrings) + { + options.Converters.Add(new JsonStringEnumConverter()); + } + + // Add custom converters for PowerShell-specific types + options.Converters.Add(new JsonConverterInt64Enum()); + options.Converters.Add(new JsonConverterPSObject(cmdlet)); + + return System.Text.Json.JsonSerializer.Serialize(objectToProcess, objectToProcess.GetType(), options); + } + catch (OperationCanceledException) + { + return null; + } + } + + private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escapeHandling) + { + return escapeHandling switch + { + NewtonsoftStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + NewtonsoftStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, + NewtonsoftStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), + _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + } + } + + /// + /// Custom JsonConverter for PSObject that handles PowerShell-specific serialization. + /// + internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.JsonConverter + { + private readonly PSCmdlet? _cmdlet; + + public JsonConverterPSObject(PSCmdlet? cmdlet) + { + _cmdlet = cmdlet; + } + + public override PSObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + if (LanguagePrimitives.IsNull(pso)) + { + writer.WriteNullValue(); + return; + } + + var obj = pso.BaseObject; + + // Handle special types - check for null-like objects + if (LanguagePrimitives.IsNull(obj) || obj is DBNull) + { + writer.WriteNullValue(); + return; + } + + // If PSObject wraps a primitive type, serialize the base object directly + if (IsPrimitiveType(obj)) + { + System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); + return; + } + + // For dictionaries and collections, convert to appropriate structure + if (obj is IDictionary dict) + { + SerializeDictionary(writer, pso, dict, options); + return; + } + + if (obj is IEnumerable enumerable and not string) + { + System.Text.Json.JsonSerializer.Serialize(writer, enumerable, options); + return; + } + + // For custom objects, serialize as dictionary with properties + SerializeAsObject(writer, pso, options); + } + + private static bool IsPrimitiveType(object obj) + { + var type = obj.GetType(); + return type.IsPrimitive + || type.IsEnum + || obj is string + || obj is decimal + || obj is DateTime + || obj is DateTimeOffset + || obj is Guid + || obj is Uri; + } + + private static void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + // Serialize dictionary entries + foreach (DictionaryEntry entry in dict) + { + string key = entry.Key?.ToString() ?? string.Empty; + writer.WritePropertyName(key); + System.Text.Json.JsonSerializer.Serialize(writer, entry.Value, options); + } + + // Add PSObject extended properties + AppendPSProperties(writer, pso, options, excludeBaseProperties: true); + + writer.WriteEndObject(); + } + + private static void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + writer.WriteStartObject(); + AppendPSProperties(writer, pso, options, excludeBaseProperties: false); + writer.WriteEndObject(); + } + + private static void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, bool excludeBaseProperties) + { + var memberTypes = excludeBaseProperties + ? PSMemberViewTypes.Extended + : (PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted); + + var properties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(memberTypes)); + + foreach (var prop in properties) + { + // Skip properties with JsonIgnore attribute or Hidden attribute + if (ShouldSkipProperty(prop)) + { + continue; + } + + try + { + var value = prop.Value; + writer.WritePropertyName(prop.Name); + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + catch + { + // Skip properties that throw on access + continue; + } + } + } + + private static bool ShouldSkipProperty(PSPropertyInfo prop) + { + // Check for Hidden attribute + if (prop.IsHidden) + { + return true; + } + + // Note: JsonIgnoreAttribute check would require reflection on the underlying member + // which may not be available for all PSPropertyInfo types. For now, we rely on + // IsHidden to filter properties that should not be serialized. + return false; + } + } + + /// + /// JsonConverter for Int64/UInt64 enums to avoid JavaScript precision issues. + /// + internal sealed class JsonConverterInt64Enum : System.Text.Json.Serialization.JsonConverter + { + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsEnum) + { + return false; + } + + var underlyingType = Enum.GetUnderlyingType(typeToConvert); + return underlyingType == typeof(long) || underlyingType == typeof(ulong); + } + + public override Enum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, Enum value, JsonSerializerOptions options) + { + // Convert to string to avoid JavaScript precision issues with large integers + writer.WriteStringValue(value.ToString("D")); + } + } + } diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs index 784db27605f..6506f2bd2ce 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/JsonObject.cs @@ -483,12 +483,6 @@ private static ICollection PopulateHashTableFromJArray(JArray list, out /// public static string ConvertToJson(object objectToProcess, in ConvertToJsonContext context) { - // Use System.Text.Json when PSJsonSerializerV2 experimental feature is enabled - if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSJsonSerializerV2)) - { - return SystemTextJsonSerializer.ConvertToJson(objectToProcess, in context); - } - try { // Pre-process the object so that it serializes the same, except that properties whose diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs deleted file mode 100644 index 6200ed70cc2..00000000000 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/SystemTextJsonSerializer.cs +++ /dev/null @@ -1,723 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -#nullable enable - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Management.Automation; -using System.Management.Automation.Internal; -using System.Numerics; -using System.Reflection; -using System.Text; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Unicode; -using System.Threading; - -using NewtonsoftStringEscapeHandling = Newtonsoft.Json.StringEscapeHandling; - -namespace Microsoft.PowerShell.Commands -{ - /// - /// Provides JSON serialization using System.Text.Json with PowerShell-specific handling. - /// - /// - /// This implementation uses Utf8JsonWriter directly instead of JsonSerializer.Serialize() - /// to provide full control over depth tracking and graceful handling of depth limits. - /// Unlike standard System.Text.Json behavior (which throws on depth exceeded), - /// this implementation converts deep objects to their string representation. - /// - internal static class SystemTextJsonSerializer - { - private static bool s_maxDepthWarningWritten; - - /// - /// Convert an object to JSON string using System.Text.Json. - /// - /// The object to convert. - /// The context for the conversion. - /// A JSON string representation of the object, or null if cancelled. - public static string? ConvertToJson(object? objectToProcess, in JsonObject.ConvertToJsonContext context) - { - try - { - s_maxDepthWarningWritten = false; - - var writerOptions = new JsonWriterOptions - { - Indented = !context.CompressOutput, - Encoder = GetEncoder(context.StringEscapeHandling), - }; - - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream, writerOptions)) - { - var serializer = new PowerShellJsonWriter( - context.MaxDepth, - context.EnumsAsStrings, - context.Cmdlet, - context.CancellationToken); - serializer.WriteValue(writer, objectToProcess); - } - - return Encoding.UTF8.GetString(stream.ToArray()); - } - catch (OperationCanceledException) - { - return null; - } - } - - private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escapeHandling) - { - return escapeHandling switch - { - NewtonsoftStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - NewtonsoftStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, - NewtonsoftStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), - _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }; - } - - /// - /// Writes the max depth warning message once per serialization. - /// - internal static void WriteMaxDepthWarning(int maxDepth, PSCmdlet? cmdlet) - { - if (s_maxDepthWarningWritten || cmdlet is null) - { - return; - } - - s_maxDepthWarningWritten = true; - string message = string.Format( - CultureInfo.CurrentCulture, - WebCmdletStrings.JsonMaxDepthReached, - maxDepth); - cmdlet.WriteWarning(message); - } - } - - /// - /// Writes PowerShell objects to JSON using an iterative (non-recursive) approach. - /// - /// - /// - /// This class uses an explicit stack instead of recursion to avoid stack overflow - /// when serializing deeply nested objects. This allows safe handling of any depth - /// up to the configured maximum without risking call stack exhaustion. - /// - /// - /// Key features: - /// - Iterative depth tracking with graceful degradation (string conversion) on depth exceeded - /// - Support for PSObject with extended/adapted properties - /// - Support for non-string dictionary keys (converted via ToString()) - /// - Respects JsonIgnoreAttribute and PowerShell's HiddenAttribute - /// - Special handling for Int64/UInt64 enums (JavaScript precision issue). - /// - /// - internal sealed class PowerShellJsonWriter - { - private readonly int _maxDepth; - private readonly bool _enumsAsStrings; - private readonly PSCmdlet? _cmdlet; - private readonly CancellationToken _cancellationToken; - - public PowerShellJsonWriter( - int maxDepth, - bool enumsAsStrings, - PSCmdlet? cmdlet, - CancellationToken cancellationToken) - { - _maxDepth = maxDepth; - _enumsAsStrings = enumsAsStrings; - _cmdlet = cmdlet; - _cancellationToken = cancellationToken; - } - - #region Stack-based Task Types - - /// - /// Represents the type of task to be processed. - /// - private enum TaskType - { - /// Write a value (may be primitive or complex). - WriteValue, - - /// Write end of JSON object. - EndObject, - - /// Write end of JSON array. - EndArray, - } - - /// - /// Represents a task on the processing stack. - /// - private readonly struct WriteTask - { - public readonly TaskType Type; - public readonly string? PropertyName; - public readonly object? Value; - public readonly PSObject? PSObject; - public readonly int Depth; - - private WriteTask(TaskType type, string? propertyName, object? value, PSObject? pso, int depth) - { - Type = type; - PropertyName = propertyName; - Value = value; - PSObject = pso; - Depth = depth; - } - - public static WriteTask ForValue(object? value, int depth, string? propertyName = null) - => new(TaskType.WriteValue, propertyName, value, value as PSObject, depth); - - public static WriteTask ForValueWithPSObject(object? value, PSObject? pso, int depth, string? propertyName = null) - => new(TaskType.WriteValue, propertyName, value, pso, depth); - - public static WriteTask ForEndObject() => new(TaskType.EndObject, null, null, null, 0); - - public static WriteTask ForEndArray() => new(TaskType.EndArray, null, null, null, 0); - } - - #endregion - - #region Main Entry Point - - /// - /// Writes a value to JSON using an iterative approach. - /// - internal void WriteValue(Utf8JsonWriter writer, object? value) - { - var stack = new Stack(); - stack.Push(WriteTask.ForValue(value, 0)); - - while (stack.Count > 0) - { - _cancellationToken.ThrowIfCancellationRequested(); - - var task = stack.Pop(); - - switch (task.Type) - { - case TaskType.EndObject: - writer.WriteEndObject(); - break; - - case TaskType.EndArray: - writer.WriteEndArray(); - break; - - case TaskType.WriteValue: - ProcessWriteValue(writer, stack, task); - break; - } - } - } - - /// - /// Processes a WriteValue task. - /// - private void ProcessWriteValue(Utf8JsonWriter writer, Stack stack, WriteTask task) - { - // Write property name if present - if (task.PropertyName is not null) - { - writer.WritePropertyName(task.PropertyName); - } - - object? value = task.Value; - int currentDepth = task.Depth; - - // Handle null - if (value is null || LanguagePrimitives.IsNull(value)) - { - writer.WriteNullValue(); - return; - } - - // Unwrap PSObject and get base object - PSObject? pso = task.PSObject ?? (value as PSObject); - object baseObject = pso?.BaseObject ?? value; - - // Handle special null-like values (NullString, DBNull) - if (TryWriteNullLike(writer, stack, baseObject, pso, currentDepth)) - { - return; - } - - // Handle primitive types (string, numbers, dates, etc.) - if (TryWritePrimitive(writer, baseObject)) - { - return; - } - - // Handle enums - if (baseObject.GetType().IsEnum) - { - WriteEnum(writer, baseObject); - return; - } - - // For complex types, check depth limit - if (currentDepth > _maxDepth) - { - WriteDepthExceeded(writer, baseObject, pso); - return; - } - - // Handle complex types by pushing tasks onto the stack - ProcessComplexValue(writer, stack, baseObject, pso, currentDepth); - } - - #endregion - - #region Primitive Types - - /// - /// Attempts to write a primitive value. Returns true if the value was handled. - /// - private static bool TryWritePrimitive(Utf8JsonWriter writer, object value) - { - switch (value) - { - case string s: - writer.WriteStringValue(s); - return true; - - case bool b: - writer.WriteBooleanValue(b); - return true; - - // Integer types - case int i: - writer.WriteNumberValue(i); - return true; - case long l: - writer.WriteNumberValue(l); - return true; - case byte by: - writer.WriteNumberValue(by); - return true; - case sbyte sb: - writer.WriteNumberValue(sb); - return true; - case short sh: - writer.WriteNumberValue(sh); - return true; - case ushort us: - writer.WriteNumberValue(us); - return true; - case uint ui: - writer.WriteNumberValue(ui); - return true; - case ulong ul: - writer.WriteNumberValue(ul); - return true; - - // Floating point types - case double d: - writer.WriteNumberValue(d); - return true; - case float f: - writer.WriteNumberValue(f); - return true; - case decimal dec: - writer.WriteNumberValue(dec); - return true; - - // BigInteger (written as raw number to preserve precision) - case BigInteger bi: - writer.WriteRawValue(bi.ToString(CultureInfo.InvariantCulture)); - return true; - - // Date/time types - case DateTime dt: - writer.WriteStringValue(dt); - return true; - case DateTimeOffset dto: - writer.WriteStringValue(dto); - return true; - - // Other simple types - case Guid g: - writer.WriteStringValue(g); - return true; - case Uri uri: - writer.WriteStringValue(uri.OriginalString); - return true; - case char c: - writer.WriteStringValue(c.ToString()); - return true; - - default: - return false; - } - } - - /// - /// Writes an enum value, handling Int64/UInt64 specially for JavaScript compatibility. - /// - private void WriteEnum(Utf8JsonWriter writer, object value) - { - if (_enumsAsStrings) - { - writer.WriteStringValue(value.ToString()); - return; - } - - // Int64/UInt64 based enums must be written as strings - // because JavaScript cannot represent them precisely - Type underlyingType = Enum.GetUnderlyingType(value.GetType()); - if (underlyingType == typeof(long) || underlyingType == typeof(ulong)) - { - writer.WriteStringValue(value.ToString()); - } - else - { - writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); - } - } - - #endregion - - #region Complex Types - - /// - /// Processes a complex value by pushing appropriate tasks onto the stack. - /// - private static void ProcessComplexValue(Utf8JsonWriter writer, Stack stack, object value, PSObject? pso, int currentDepth) - { - // Handle Newtonsoft.Json JObject (for backward compatibility) - if (value is Newtonsoft.Json.Linq.JObject jObject) - { - ProcessDictionary(writer, stack, jObject.ToObject>()!, null, currentDepth); - return; - } - - // Handle dictionaries - if (value is IDictionary dict) - { - ProcessDictionary(writer, stack, dict, pso, currentDepth); - return; - } - - // Handle enumerables (arrays, lists, etc.) - if (value is IEnumerable enumerable) - { - ProcessArray(writer, stack, enumerable, currentDepth); - return; - } - - // Handle custom objects (classes, structs) - ProcessCustomObject(writer, stack, value, pso, currentDepth); - } - - /// - /// Processes a dictionary by pushing tasks for each entry onto the stack. - /// - private static void ProcessDictionary(Utf8JsonWriter writer, Stack stack, IDictionary dict, PSObject? pso, int currentDepth) - { - writer.WriteStartObject(); - - // Collect entries to push in reverse order (stack is LIFO) - var entries = new List<(string Key, object? Value)>(); - - foreach (DictionaryEntry entry in dict) - { - string key = entry.Key?.ToString() ?? string.Empty; - entries.Add((key, entry.Value)); - } - - // Add extended properties if present - if (pso is not null) - { - CollectExtendedProperties(entries, pso, dict, currentDepth, isCustomObject: false); - } - - // Push EndObject first (will be processed last) - stack.Push(WriteTask.ForEndObject()); - - // Push entries in reverse order - for (int i = entries.Count - 1; i >= 0; i--) - { - stack.Push(WriteTask.ForValue(entries[i].Value, currentDepth + 1, entries[i].Key)); - } - } - - /// - /// Processes an array by pushing tasks for each element onto the stack. - /// - private static void ProcessArray(Utf8JsonWriter writer, Stack stack, IEnumerable enumerable, int currentDepth) - { - writer.WriteStartArray(); - - // Collect items to push in reverse order - var items = new List(); - foreach (object? item in enumerable) - { - items.Add(item); - } - - // Push EndArray first (will be processed last) - stack.Push(WriteTask.ForEndArray()); - - // Push items in reverse order - for (int i = items.Count - 1; i >= 0; i--) - { - stack.Push(WriteTask.ForValue(items[i], currentDepth + 1)); - } - } - - /// - /// Processes a custom object by pushing tasks for each property onto the stack. - /// - private static void ProcessCustomObject(Utf8JsonWriter writer, Stack stack, object value, PSObject? pso, int currentDepth) - { - writer.WriteStartObject(); - - Type type = value.GetType(); - var entries = new List<(string Key, object? Value)>(); - var writtenProperties = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Collect public fields - foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Instance)) - { - if (ShouldSkipMember(field)) - { - continue; - } - - object? fieldValue = TryGetFieldValue(field, value); - entries.Add((field.Name, fieldValue)); - writtenProperties.Add(field.Name); - } - - // Collect public properties - foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (ShouldSkipMember(property)) - { - continue; - } - - MethodInfo? getter = property.GetGetMethod(); - if (getter is null || getter.GetParameters().Length > 0) - { - continue; - } - - object? propertyValue = TryGetPropertyValue(getter, value); - entries.Add((property.Name, propertyValue)); - writtenProperties.Add(property.Name); - } - - // Add extended properties from PSObject - if (pso is not null) - { - CollectExtendedProperties(entries, pso, writtenProperties, currentDepth, isCustomObject: true); - } - - // Push EndObject first (will be processed last) - stack.Push(WriteTask.ForEndObject()); - - // Push entries in reverse order - for (int i = entries.Count - 1; i >= 0; i--) - { - stack.Push(WriteTask.ForValue(entries[i].Value, currentDepth + 1, entries[i].Key)); - } - } - - #endregion - - #region PSObject Support - - /// - /// Handles NullString and DBNull values, which may have extended properties. - /// - private static bool TryWriteNullLike(Utf8JsonWriter writer, Stack stack, object value, PSObject? pso, int currentDepth) - { - if (value != System.Management.Automation.Language.NullString.Value && value != DBNull.Value) - { - return false; - } - - if (pso is not null && HasExtendedProperties(pso)) - { - ProcessObjectWithNullValue(writer, stack, pso, currentDepth); - } - else - { - writer.WriteNullValue(); - } - - return true; - } - - /// - /// Processes an object with a null base value but with extended properties. - /// - private static void ProcessObjectWithNullValue(Utf8JsonWriter writer, Stack stack, PSObject pso, int currentDepth) - { - writer.WriteStartObject(); - - var entries = new List<(string Key, object? Value)> - { - ("value", null), - }; - - CollectExtendedProperties(entries, pso, writtenKeys: null, currentDepth, isCustomObject: false); - - // Push EndObject first - stack.Push(WriteTask.ForEndObject()); - - // Push entries in reverse order - for (int i = entries.Count - 1; i >= 0; i--) - { - stack.Push(WriteTask.ForValue(entries[i].Value, currentDepth + 1, entries[i].Key)); - } - } - - /// - /// Collects extended (and optionally adapted) properties from a PSObject. - /// - private static void CollectExtendedProperties( - List<(string Key, object? Value)> entries, - PSObject pso, - object? writtenKeys, - int currentDepth, - bool isCustomObject) - { - // DateTime and String should not have extended properties appended - if (pso.BaseObject is string || pso.BaseObject is DateTime) - { - return; - } - - PSMemberViewTypes viewTypes = isCustomObject - ? PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted - : PSMemberViewTypes.Extended; - - var properties = new PSMemberInfoIntegratingCollection( - pso, - PSObject.GetPropertyCollection(viewTypes)); - - foreach (PSPropertyInfo prop in properties) - { - if (IsPropertyAlreadyWritten(prop.Name, writtenKeys)) - { - continue; - } - - object? propValue = TryGetPSPropertyValue(prop); - entries.Add((prop.Name, propValue)); - } - } - - private static bool HasExtendedProperties(PSObject pso) - { - var properties = new PSMemberInfoIntegratingCollection( - pso, - PSObject.GetPropertyCollection(PSMemberViewTypes.Extended)); - - foreach (var _ in properties) - { - return true; - } - - return false; - } - - private static bool IsPropertyAlreadyWritten(string name, object? writtenKeys) - { - return writtenKeys switch - { - IDictionary dict => dict.Contains(name), - HashSet hashSet => hashSet.Contains(name), - _ => false, - }; - } - - #endregion - - #region Helper Methods - - /// - /// Writes a string representation when max depth is exceeded. - /// - private void WriteDepthExceeded(Utf8JsonWriter writer, object value, PSObject? pso) - { - SystemTextJsonSerializer.WriteMaxDepthWarning(_maxDepth, _cmdlet); - - string stringValue = pso is not null && pso.ImmediateBaseObjectIsEmpty - ? LanguagePrimitives.ConvertTo(pso) - : LanguagePrimitives.ConvertTo(value); - - writer.WriteStringValue(stringValue); - } - - /// - /// Checks if a member should be skipped during serialization. - /// - private static bool ShouldSkipMember(MemberInfo member) - { - return member.IsDefined(typeof(JsonIgnoreAttribute), inherit: true) - || member.IsDefined(typeof(HiddenAttribute), inherit: true); - } - - /// - /// Safely gets a field value, returning null on exception. - /// - private static object? TryGetFieldValue(FieldInfo field, object obj) - { - try - { - return field.GetValue(obj); - } - catch - { - return null; - } - } - - /// - /// Safely gets a property value, returning null on exception. - /// - private static object? TryGetPropertyValue(MethodInfo getter, object obj) - { - try - { - return getter.Invoke(obj, Array.Empty()); - } - catch - { - return null; - } - } - - /// - /// Safely gets a PSPropertyInfo value, returning null on exception. - /// - private static object? TryGetPSPropertyValue(PSPropertyInfo prop) - { - try - { - return prop.Value; - } - catch - { - return null; - } - } - - #endregion - } -} diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index 94638609daa..1a2345dc38b 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -7,32 +7,33 @@ BeforeDiscovery { Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { Context "Default values and limits" { - It "V2: Default depth should be 64" -Skip:(-not $script:isV2Enabled) { - # Create a 64-level deep object + It "V2: Default depth should be unlimited" -Skip:(-not $script:isV2Enabled) { + # Create a 200-level deep object - should work with unlimited default depth $obj = @{ level = 0 } - for ($i = 1; $i -le 63; $i++) { + for ($i = 1; $i -lt 200; $i++) { $obj = @{ level = $i; child = $obj } } - # This should work without truncation at default depth 64 + # This should work without truncation at unlimited default depth $json = $obj | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Match '"level":63' + $json | Should -Match '"level":199' + $warn | Should -BeNullOrEmpty } It "V2: Large depth values should be allowed" -Skip:(-not $script:isV2Enabled) { { ConvertTo-Json -InputObject @{a=1} -Depth 10000 } | Should -Not -Throw } - It "V2: Depth -1 should work as unlimited" -Skip:(-not $script:isV2Enabled) { - $obj = @{ level = 0 } - for ($i = 1; $i -lt 200; $i++) { - $obj = @{ level = $i; child = $obj } - } - $json = $obj | ConvertTo-Json -Depth -1 -Compress -WarningAction SilentlyContinue - $json | Should -Match '"level":199' + It "V2: Depth 0 or negative should throw" -Skip:(-not $script:isV2Enabled) { + { ConvertTo-Json -InputObject @{a=1} -Depth 0 } | Should -Throw + { ConvertTo-Json -InputObject @{a=1} -Depth -1 } | Should -Throw + { ConvertTo-Json -InputObject @{a=1} -Depth -2 } | Should -Throw } - It "V2: Negative depth other than -1 should throw" -Skip:(-not $script:isV2Enabled) { - { ConvertTo-Json -InputObject @{a=1} -Depth -2 } | Should -Throw + It "V2: Minimum depth of 1 should work" -Skip:(-not $script:isV2Enabled) { + $obj = @{ a = @{ b = 1 } } + $json = $obj | ConvertTo-Json -Depth 1 -Compress -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Match '"a":' + $warn | Should -Not -BeNullOrEmpty # depth exceeded warning } It "Legacy: Depth over 100 should throw when V2 is disabled" -Skip:$script:isV2Enabled { @@ -40,6 +41,55 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } } + Context "Circular reference detection" { + It "V2: Should detect self-referencing object" -Skip:(-not $script:isV2Enabled) { + $obj = [pscustomobject]@{ Name = "Test"; Self = $null } + $obj.Self = $obj + $json = $obj | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + $warn | Should -Match 'Circular reference' + } + + It "V2: Should detect circular reference in nested objects" -Skip:(-not $script:isV2Enabled) { + $parent = [pscustomobject]@{ Name = "Parent"; Child = $null } + $child = [pscustomobject]@{ Name = "Child"; Parent = $null } + $parent.Child = $child + $child.Parent = $parent + $json = $parent | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + $warn | Should -Match 'Circular reference' + } + + It "V2: Should detect circular reference in hashtable" -Skip:(-not $script:isV2Enabled) { + $hash = @{ Name = "Test" } + $hash.Self = $hash + $json = $hash | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + $warn | Should -Match 'Circular reference' + } + + It "V2: Should detect circular reference in array" -Skip:(-not $script:isV2Enabled) { + $arr = @(1, 2, $null) + $arr[2] = $arr + $json = ConvertTo-Json -InputObject $arr -Compress -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + $warn | Should -Match 'Circular reference' + } + + It "V2: Should handle same object appearing multiple times (not circular)" -Skip:(-not $script:isV2Enabled) { + $shared = @{ value = 42 } + $obj = @{ first = $shared; second = $shared } + # Same object appearing in different branches is fine, not circular + $json = $obj | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Match '"value":42' + # Note: This may or may not produce a warning depending on implementation + } + } + Context "Depth exceeded warning" { It "V2: Should output warning when depth is exceeded" -Skip:(-not $script:isV2Enabled) { $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } @@ -51,8 +101,8 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { It "V2: Should convert deep objects to string when depth exceeded" -Skip:(-not $script:isV2Enabled) { $inner = [pscustomobject]@{ value = "deep" } $outer = [pscustomobject]@{ child = $inner } - $json = $outer | ConvertTo-Json -Depth 0 -Compress -WarningVariable warn -WarningAction SilentlyContinue - # At depth 0, child should be converted to string + $json = $outer | ConvertTo-Json -Depth 1 -Compress -WarningVariable warn -WarningAction SilentlyContinue + # At depth 1, child should be converted to string $json | Should -Match '"child":' $warn | Should -Not -BeNullOrEmpty } From dce72a4b024e88e622c1407d3c4d75536976a398 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Thu, 18 Dec 2025 08:07:20 +0900 Subject: [PATCH 09/42] Fix null value serialization in PSCustomObject properties Fixed issue where PSCustomObject properties with null values were serialized as {prop:} instead of {prop:null}. Root cause: The check 'value is null' does not detect PowerShells AutomationNull.Value. Solution: Changed to use LanguagePrimitives.IsNull(value) which properly handles PowerShells null representations. Test results: - Before: 11 passed, 2 failed - After: 12 passed, 1 failed, 1 skipped - Remaining failure: DateTime timezone (acceptable per user guidance) All critical functionality now working correctly. --- .../utility/WebCmdlet/ConvertToJsonCommand.cs | 194 +++++++++++++++++- 1 file changed, 184 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index d1cfff1adaf..0500b96da6c 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; using System.Numerics; @@ -337,9 +338,41 @@ internal static class SystemTextJsonSerializer // Add custom converters for PowerShell-specific types options.Converters.Add(new JsonConverterInt64Enum()); - options.Converters.Add(new JsonConverterPSObject(cmdlet)); + options.Converters.Add(new JsonConverterBigInteger()); + options.Converters.Add(new JsonConverterNullString()); + options.Converters.Add(new JsonConverterDBNull()); + options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth)); - return System.Text.Json.JsonSerializer.Serialize(objectToProcess, objectToProcess.GetType(), options); + // Handle JObject specially to avoid IEnumerable serialization + if (objectToProcess is Newtonsoft.Json.Linq.JObject jObj) + { + // Serialize JObject directly using our custom logic + using var stream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = !compressOutput, Encoder = GetEncoder(stringEscapeHandling) })) + { + writer.WriteStartObject(); + foreach (var prop in jObj.Properties()) + { + writer.WritePropertyName(prop.Name); + var value = prop.Value.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => prop.Value.ToString() + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + writer.WriteEndObject(); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + // Wrap in PSObject to ensure ETS properties are preserved + var pso = PSObject.AsPSObject(objectToProcess); + return System.Text.Json.JsonSerializer.Serialize(pso, typeof(PSObject), options); } catch (OperationCanceledException) { @@ -365,10 +398,12 @@ private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escap internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.JsonConverter { private readonly PSCmdlet? _cmdlet; + private readonly int _maxDepth; - public JsonConverterPSObject(PSCmdlet? cmdlet) + public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) { _cmdlet = cmdlet; + _maxDepth = maxDepth; } public override PSObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -386,13 +421,50 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp var obj = pso.BaseObject; + // Check if PSObject has Extended/Adapted properties + bool hasETSProperties = pso.Properties.Match("*", PSMemberTypes.NoteProperty | PSMemberTypes.AliasProperty).Count > 0; + // Handle special types - check for null-like objects - if (LanguagePrimitives.IsNull(obj) || obj is DBNull) + if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) { - writer.WriteNullValue(); + // If there are ETS properties, serialize as object with those properties + if (hasETSProperties) + { + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + AppendPSProperties(writer, pso, options, excludeBaseProperties: true); + writer.WriteEndObject(); + } + else + { + writer.WriteNullValue(); + } return; } + // Handle Newtonsoft.Json.Linq.JObject by converting properties manually + if (obj is Newtonsoft.Json.Linq.JObject jObject) + { + writer.WriteStartObject(); + foreach (var prop in jObject.Properties()) + { + writer.WritePropertyName(prop.Name); + // Convert JToken value to appropriate .NET type + var value = prop.Value.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => prop.Value.ToString() + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + writer.WriteEndObject(); + return; + } // If PSObject wraps a primitive type, serialize the base object directly if (IsPrimitiveType(obj)) { @@ -430,7 +502,12 @@ private static bool IsPrimitiveType(object obj) || obj is Uri; } - private static void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options) + private static bool IsPrimitiveTypeOrNull(object? obj) + { + return obj is null || IsPrimitiveType(obj); + } + + private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options) { writer.WriteStartObject(); @@ -439,7 +516,36 @@ private static void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDi { string key = entry.Key?.ToString() ?? string.Empty; writer.WritePropertyName(key); - System.Text.Json.JsonSerializer.Serialize(writer, entry.Value, options); + + // If maxDepth is 0, convert non-primitive values to string + if (_maxDepth == 0 && entry.Value is not null && !IsPrimitiveTypeOrNull(entry.Value)) + { + writer.WriteStringValue(entry.Value.ToString()); + } + // Handle Newtonsoft.Json.Linq.JObject specially + else if (entry.Value is Newtonsoft.Json.Linq.JObject jObject) + { + writer.WriteStartObject(); + foreach (var prop in jObject.Properties()) + { + writer.WritePropertyName(prop.Name); + var value = prop.Value.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => prop.Value.ToString() + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + writer.WriteEndObject(); + } + else + { + System.Text.Json.JsonSerializer.Serialize(writer, entry.Value, options); + } } // Add PSObject extended properties @@ -448,14 +554,14 @@ private static void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDi writer.WriteEndObject(); } - private static void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) { writer.WriteStartObject(); AppendPSProperties(writer, pso, options, excludeBaseProperties: false); writer.WriteEndObject(); } - private static void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, bool excludeBaseProperties) + private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, bool excludeBaseProperties) { var memberTypes = excludeBaseProperties ? PSMemberViewTypes.Extended @@ -477,7 +583,26 @@ private static void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, Json { var value = prop.Value; writer.WritePropertyName(prop.Name); - System.Text.Json.JsonSerializer.Serialize(writer, value, options); + + // If maxDepth is 0, convert non-primitive values to string + if (_maxDepth == 0 && value is not null && !IsPrimitiveTypeOrNull(value)) + { + writer.WriteStringValue(value.ToString()); + } + else + { + // Handle null values directly (including AutomationNull) + if (LanguagePrimitives.IsNull(value)) + { + writer.WriteNullValue(); + } + else + { + // Wrap value in PSObject to ensure custom converters are applied + var psoValue = PSObject.AsPSObject(value); + System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); + } + } } catch { @@ -530,4 +655,53 @@ public override void Write(Utf8JsonWriter writer, Enum value, JsonSerializerOpti } } + /// + /// JsonConverter for NullString to serialize as null. + /// + internal sealed class JsonConverterNullString : System.Text.Json.Serialization.JsonConverter + { + public override System.Management.Automation.Language.NullString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, System.Management.Automation.Language.NullString value, JsonSerializerOptions options) + { + writer.WriteNullValue(); + } + } + + /// + /// JsonConverter for DBNull to serialize as null. + /// + internal sealed class JsonConverterDBNull : System.Text.Json.Serialization.JsonConverter + { + public override DBNull? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, DBNull value, JsonSerializerOptions options) + { + writer.WriteNullValue(); + } + } + + /// + /// JsonConverter for BigInteger to serialize as number string. + /// + internal sealed class JsonConverterBigInteger : System.Text.Json.Serialization.JsonConverter + { + public override BigInteger Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializerOptions options) + { + // Write as number string to preserve precision + writer.WriteRawValue(value.ToString(CultureInfo.InvariantCulture)); + } + } + } From 31cd184e623287b2dcf07faad5e8691dfc1cf0de Mon Sep 17 00:00:00 2001 From: yotsuda Date: Thu, 18 Dec 2025 08:49:41 +0900 Subject: [PATCH 10/42] Add ReferenceHandler.IgnoreCycles to prevent circular reference loops Added ReferenceHandler.IgnoreCycles to JsonSerializerOptions to detect and handle circular references automatically using .NET's built-in mechanism. This provides partial mitigation for depth-related issues by preventing infinite loops in circular object graphs, though it does not fully solve the depth tracking issue for deeply nested non-circular objects (e.g., Type properties). Related to feedback from jborean93 regarding depth issues. Full depth tracking implementation will follow in a subsequent commit. --- .../commands/utility/WebCmdlet/ConvertToJsonCommand.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index 0500b96da6c..33ffa8d1d32 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -329,6 +329,7 @@ internal static class SystemTextJsonSerializer MaxDepth = maxDepth, DefaultIgnoreCondition = JsonIgnoreCondition.Never, Encoder = GetEncoder(stringEscapeHandling), + ReferenceHandler = ReferenceHandler.IgnoreCycles, }; if (enumsAsStrings) From 553f0d19ab8b3cc75cc97ef651d76c79b974ddff Mon Sep 17 00:00:00 2001 From: yotsuda Date: Fri, 19 Dec 2025 19:49:28 +0900 Subject: [PATCH 11/42] Fix depth tracking to not limit primitive types in JsonConverterPSObject --- .../utility/WebCmdlet/ConvertToJsonCommand.cs | 219 ++++++++++++------ 1 file changed, 152 insertions(+), 67 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index 33ffa8d1d32..c0929a97781 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -323,10 +323,15 @@ internal static class SystemTextJsonSerializer try { + // Reset depth tracking for this serialization + JsonConverterPSObject.ResetDepthTracking(); + var options = new JsonSerializerOptions() { WriteIndented = !compressOutput, - MaxDepth = maxDepth, + // Use maximum allowed depth to avoid System.Text.Json exceptions + // Actual depth limiting is handled by JsonConverterPSObject + MaxDepth = 1000, DefaultIgnoreCondition = JsonIgnoreCondition.Never, Encoder = GetEncoder(stringEscapeHandling), ReferenceHandler = ReferenceHandler.IgnoreCycles, @@ -401,6 +406,19 @@ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.Jso private readonly PSCmdlet? _cmdlet; private readonly int _maxDepth; + // Depth tracking across recursive calls + private static readonly AsyncLocal s_currentDepth = new(); + private static readonly AsyncLocal s_warningWritten = new(); + + /// + /// Reset depth tracking for a new serialization operation. + /// + public static void ResetDepthTracking() + { + s_currentDepth.Value = 0; + s_warningWritten.Value = false; + } + public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) { _cmdlet = cmdlet; @@ -422,72 +440,149 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp var obj = pso.BaseObject; - // Check if PSObject has Extended/Adapted properties - bool hasETSProperties = pso.Properties.Match("*", PSMemberTypes.NoteProperty | PSMemberTypes.AliasProperty).Count > 0; + // Check depth limit BEFORE incrementing + int currentDepth = s_currentDepth.Value; + if (currentDepth > _maxDepth) + { + WriteDepthExceeded(writer, pso, obj); + return; + } - // Handle special types - check for null-like objects + // Handle special types - check for null-like objects (no depth increment needed) if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) { - // If there are ETS properties, serialize as object with those properties - if (hasETSProperties) + // Check if PSObject has Extended/Adapted properties + bool hasETSProps = pso.Properties.Match("*", PSMemberTypes.NoteProperty | PSMemberTypes.AliasProperty).Count > 0; + if (hasETSProps) { - writer.WriteStartObject(); - writer.WritePropertyName("value"); - writer.WriteNullValue(); - AppendPSProperties(writer, pso, options, excludeBaseProperties: true); - writer.WriteEndObject(); + s_currentDepth.Value = currentDepth + 1; + try + { + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + AppendPSProperties(writer, pso, options, excludeBaseProperties: true); + writer.WriteEndObject(); + } + finally + { + s_currentDepth.Value = currentDepth; + } } else { writer.WriteNullValue(); } + return; } // Handle Newtonsoft.Json.Linq.JObject by converting properties manually if (obj is Newtonsoft.Json.Linq.JObject jObject) { - writer.WriteStartObject(); - foreach (var prop in jObject.Properties()) + s_currentDepth.Value = currentDepth + 1; + try { - writer.WritePropertyName(prop.Name); - // Convert JToken value to appropriate .NET type - var value = prop.Value.Type switch + writer.WriteStartObject(); + foreach (var prop in jObject.Properties()) { - Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => prop.Value.ToString() - }; - System.Text.Json.JsonSerializer.Serialize(writer, value, options); + writer.WritePropertyName(prop.Name); + WriteJTokenValue(writer, prop.Value, options); + } + + writer.WriteEndObject(); } - writer.WriteEndObject(); + finally + { + s_currentDepth.Value = currentDepth; + } + return; } - // If PSObject wraps a primitive type, serialize the base object directly + + // If PSObject wraps a primitive type, serialize the base object directly (no depth increment) if (IsPrimitiveType(obj)) { System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); return; } - // For dictionaries and collections, convert to appropriate structure - if (obj is IDictionary dict) + // For dictionaries, collections, and custom objects - increment depth + s_currentDepth.Value = currentDepth + 1; + try { - SerializeDictionary(writer, pso, dict, options); - return; + if (obj is IDictionary dict) + { + SerializeDictionary(writer, pso, dict, options); + } + else if (obj is IEnumerable enumerable and not string) + { + SerializeEnumerable(writer, enumerable, options); + } + else + { + // For custom objects, serialize as dictionary with properties + SerializeAsObject(writer, pso, options); + } + } + finally + { + s_currentDepth.Value = currentDepth; + } + } + + private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) + { + // Write warning once + if (!s_warningWritten.Value && _cmdlet is not null) + { + s_warningWritten.Value = true; + string warningMessage = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.", + _maxDepth); + _cmdlet.WriteWarning(warningMessage); } - if (obj is IEnumerable enumerable and not string) + // Convert to string when depth exceeded + string stringValue = pso.ImmediateBaseObjectIsEmpty + ? LanguagePrimitives.ConvertTo(pso) + : LanguagePrimitives.ConvertTo(obj); + writer.WriteStringValue(stringValue); + } + + private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options) + { + var value = token.Type switch { - System.Text.Json.JsonSerializer.Serialize(writer, enumerable, options); - return; + Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => token.ToString() + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + + private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var item in enumerable) + { + if (item is null) + { + writer.WriteNullValue(); + } + else + { + var psoItem = PSObject.AsPSObject(item); + // Recursive call - Write will handle depth tracking + Write(writer, psoItem, options); + } } - // For custom objects, serialize as dictionary with properties - SerializeAsObject(writer, pso, options); + writer.WriteEndArray(); } private static bool IsPrimitiveType(object obj) @@ -500,7 +595,8 @@ private static bool IsPrimitiveType(object obj) || obj is DateTime || obj is DateTimeOffset || obj is Guid - || obj is Uri; + || obj is Uri + || obj is BigInteger; } private static bool IsPrimitiveTypeOrNull(object? obj) @@ -517,36 +613,7 @@ private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionar { string key = entry.Key?.ToString() ?? string.Empty; writer.WritePropertyName(key); - - // If maxDepth is 0, convert non-primitive values to string - if (_maxDepth == 0 && entry.Value is not null && !IsPrimitiveTypeOrNull(entry.Value)) - { - writer.WriteStringValue(entry.Value.ToString()); - } - // Handle Newtonsoft.Json.Linq.JObject specially - else if (entry.Value is Newtonsoft.Json.Linq.JObject jObject) - { - writer.WriteStartObject(); - foreach (var prop in jObject.Properties()) - { - writer.WritePropertyName(prop.Name); - var value = prop.Value.Type switch - { - Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => prop.Value.ToString() - }; - System.Text.Json.JsonSerializer.Serialize(writer, value, options); - } - writer.WriteEndObject(); - } - else - { - System.Text.Json.JsonSerializer.Serialize(writer, entry.Value, options); - } + WriteValue(writer, entry.Value, options); } // Add PSObject extended properties @@ -555,6 +622,24 @@ private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionar writer.WriteEndObject(); } + private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else if (IsPrimitiveType(value)) + { + System.Text.Json.JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + else + { + // Non-primitive: wrap in PSObject and call Write for depth tracking + var psoValue = PSObject.AsPSObject(value); + Write(writer, psoValue, options); + } + } + private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) { writer.WriteStartObject(); @@ -584,7 +669,7 @@ private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSeriali { var value = prop.Value; writer.WritePropertyName(prop.Name); - + // If maxDepth is 0, convert non-primitive values to string if (_maxDepth == 0 && value is not null && !IsPrimitiveTypeOrNull(value)) { From 68ca191f2ac7bdc3d0c91c7a71e3c85f62a1ba9b Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sat, 20 Dec 2025 10:32:02 +0900 Subject: [PATCH 12/42] Split V1/V2 into separate files and add -JsonSerializerOptions parameter for custom converter support --- .../utility/WebCmdlet/ConvertToJsonCommand.cs | 642 ---------------- .../WebCmdlet/ConvertToJsonCommandV2.cs | 685 ++++++++++++++++++ 2 files changed, 685 insertions(+), 642 deletions(-) create mode 100644 src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs index c0929a97781..9ac93147651 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -2,27 +2,13 @@ // Licensed under the MIT License. using System; -using System.Collections; using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; -using System.Numerics; -using System.Reflection; -using System.Text; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Unicode; using System.Threading; using Newtonsoft.Json; -using NewtonsoftStringEscapeHandling = Newtonsoft.Json.StringEscapeHandling; -using StjJsonIgnoreAttribute = System.Text.Json.Serialization.JsonIgnoreAttribute; - namespace Microsoft.PowerShell.Commands { /// @@ -162,632 +148,4 @@ protected override void StopProcessing() _cancellationSource.Cancel(); } } - -#nullable enable - /// - /// The ConvertTo-Json command (V2 - System.Text.Json implementation). - /// This command converts an object to a Json string representation. - /// - /// - /// This class is shown when PSJsonSerializerV2 experimental feature is enabled. - /// V2 uses System.Text.Json with circular reference detection and unlimited depth by default. - /// - [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Show)] - [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)] - [OutputType(typeof(string))] - public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable - { - /// - /// Gets or sets the InputObject property. - /// - [Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true)] - [AllowNull] - public object? InputObject { get; set; } - - private readonly CancellationTokenSource _cancellationSource = new(); - - /// - /// Gets or sets the Depth property. - /// Default is 64. Maximum allowed depth is 1000 due to System.Text.Json limitations. - /// Use 0 to serialize only top-level properties. - /// - [Parameter] - [ValidateRange(0, 1000)] - public int Depth { get; set; } = 64; - - /// - /// Gets or sets the Compress property. - /// If the Compress property is set to be true, the Json string will - /// be output in the compressed way. Otherwise, the Json string will - /// be output with indentations. - /// - [Parameter] - public SwitchParameter Compress { get; set; } - - /// - /// Gets or sets the EnumsAsStrings property. - /// If the EnumsAsStrings property is set to true, enum values will - /// be converted to their string equivalent. Otherwise, enum values - /// will be converted to their numeric equivalent. - /// - [Parameter] - public SwitchParameter EnumsAsStrings { get; set; } - - /// - /// Gets or sets the AsArray property. - /// If the AsArray property is set to be true, the result JSON string will - /// be returned with surrounding '[', ']' chars. Otherwise, - /// the array symbols will occur only if there is more than one input object. - /// - [Parameter] - public SwitchParameter AsArray { get; set; } - - /// - /// Specifies how strings are escaped when writing JSON text. - /// If the EscapeHandling property is set to EscapeHtml, the result JSON string will - /// be returned with HTML (<, >, &, ', ") and control characters (e.g. newline) are escaped. - /// - [Parameter] - public NewtonsoftStringEscapeHandling EscapeHandling { get; set; } = NewtonsoftStringEscapeHandling.Default; - - /// - /// IDisposable implementation, dispose of any disposable resources created by the cmdlet. - /// - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - /// - /// Implementation of IDisposable for both manual Dispose() and finalizer-called disposal of resources. - /// - /// - /// Specified as true when Dispose() was called, false if this is called from the finalizer. - /// - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _cancellationSource.Dispose(); - } - } - - private readonly List _inputObjects = new(); - - /// - /// Caching the input objects for the command. - /// - protected override void ProcessRecord() - { - _inputObjects.Add(InputObject); - } - - /// - /// Do the conversion to json and write output. - /// - protected override void EndProcessing() - { - if (_inputObjects.Count > 0) - { - object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (_inputObjects.ToArray() as object) : _inputObjects[0]; - - string? output = SystemTextJsonSerializer.ConvertToJson( - objectToProcess, - Depth, - EnumsAsStrings.IsPresent, - Compress.IsPresent, - EscapeHandling, - this, - _cancellationSource.Token); - - // null is returned only if the pipeline is stopping (e.g. ctrl+c is signaled). - // in that case, we shouldn't write the null to the output pipe. - if (output is not null) - { - WriteObject(output); - } - } - } - - /// - /// Process the Ctrl+C signal. - /// - protected override void StopProcessing() - { - _cancellationSource.Cancel(); - } - } - - /// - /// Provides JSON serialization using System.Text.Json with PowerShell-specific handling. - /// - internal static class SystemTextJsonSerializer - { - /// - /// Convert an object to JSON string using System.Text.Json. - /// - public static string? ConvertToJson( - object? objectToProcess, - int maxDepth, - bool enumsAsStrings, - bool compressOutput, - NewtonsoftStringEscapeHandling stringEscapeHandling, - PSCmdlet? cmdlet, - CancellationToken cancellationToken) - { - if (objectToProcess is null) - { - return "null"; - } - - try - { - // Reset depth tracking for this serialization - JsonConverterPSObject.ResetDepthTracking(); - - var options = new JsonSerializerOptions() - { - WriteIndented = !compressOutput, - // Use maximum allowed depth to avoid System.Text.Json exceptions - // Actual depth limiting is handled by JsonConverterPSObject - MaxDepth = 1000, - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - Encoder = GetEncoder(stringEscapeHandling), - ReferenceHandler = ReferenceHandler.IgnoreCycles, - }; - - if (enumsAsStrings) - { - options.Converters.Add(new JsonStringEnumConverter()); - } - - // Add custom converters for PowerShell-specific types - options.Converters.Add(new JsonConverterInt64Enum()); - options.Converters.Add(new JsonConverterBigInteger()); - options.Converters.Add(new JsonConverterNullString()); - options.Converters.Add(new JsonConverterDBNull()); - options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth)); - - // Handle JObject specially to avoid IEnumerable serialization - if (objectToProcess is Newtonsoft.Json.Linq.JObject jObj) - { - // Serialize JObject directly using our custom logic - using var stream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = !compressOutput, Encoder = GetEncoder(stringEscapeHandling) })) - { - writer.WriteStartObject(); - foreach (var prop in jObj.Properties()) - { - writer.WritePropertyName(prop.Name); - var value = prop.Value.Type switch - { - Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => prop.Value.ToString() - }; - System.Text.Json.JsonSerializer.Serialize(writer, value, options); - } - writer.WriteEndObject(); - } - return System.Text.Encoding.UTF8.GetString(stream.ToArray()); - } - - // Wrap in PSObject to ensure ETS properties are preserved - var pso = PSObject.AsPSObject(objectToProcess); - return System.Text.Json.JsonSerializer.Serialize(pso, typeof(PSObject), options); - } - catch (OperationCanceledException) - { - return null; - } - } - - private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escapeHandling) - { - return escapeHandling switch - { - NewtonsoftStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - NewtonsoftStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, - NewtonsoftStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), - _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }; - } - } - - /// - /// Custom JsonConverter for PSObject that handles PowerShell-specific serialization. - /// - internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.JsonConverter - { - private readonly PSCmdlet? _cmdlet; - private readonly int _maxDepth; - - // Depth tracking across recursive calls - private static readonly AsyncLocal s_currentDepth = new(); - private static readonly AsyncLocal s_warningWritten = new(); - - /// - /// Reset depth tracking for a new serialization operation. - /// - public static void ResetDepthTracking() - { - s_currentDepth.Value = 0; - s_warningWritten.Value = false; - } - - public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) - { - _cmdlet = cmdlet; - _maxDepth = maxDepth; - } - - public override PSObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) - { - if (LanguagePrimitives.IsNull(pso)) - { - writer.WriteNullValue(); - return; - } - - var obj = pso.BaseObject; - - // Check depth limit BEFORE incrementing - int currentDepth = s_currentDepth.Value; - if (currentDepth > _maxDepth) - { - WriteDepthExceeded(writer, pso, obj); - return; - } - - // Handle special types - check for null-like objects (no depth increment needed) - if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) - { - // Check if PSObject has Extended/Adapted properties - bool hasETSProps = pso.Properties.Match("*", PSMemberTypes.NoteProperty | PSMemberTypes.AliasProperty).Count > 0; - if (hasETSProps) - { - s_currentDepth.Value = currentDepth + 1; - try - { - writer.WriteStartObject(); - writer.WritePropertyName("value"); - writer.WriteNullValue(); - AppendPSProperties(writer, pso, options, excludeBaseProperties: true); - writer.WriteEndObject(); - } - finally - { - s_currentDepth.Value = currentDepth; - } - } - else - { - writer.WriteNullValue(); - } - - return; - } - - // Handle Newtonsoft.Json.Linq.JObject by converting properties manually - if (obj is Newtonsoft.Json.Linq.JObject jObject) - { - s_currentDepth.Value = currentDepth + 1; - try - { - writer.WriteStartObject(); - foreach (var prop in jObject.Properties()) - { - writer.WritePropertyName(prop.Name); - WriteJTokenValue(writer, prop.Value, options); - } - - writer.WriteEndObject(); - } - finally - { - s_currentDepth.Value = currentDepth; - } - - return; - } - - // If PSObject wraps a primitive type, serialize the base object directly (no depth increment) - if (IsPrimitiveType(obj)) - { - System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); - return; - } - - // For dictionaries, collections, and custom objects - increment depth - s_currentDepth.Value = currentDepth + 1; - try - { - if (obj is IDictionary dict) - { - SerializeDictionary(writer, pso, dict, options); - } - else if (obj is IEnumerable enumerable and not string) - { - SerializeEnumerable(writer, enumerable, options); - } - else - { - // For custom objects, serialize as dictionary with properties - SerializeAsObject(writer, pso, options); - } - } - finally - { - s_currentDepth.Value = currentDepth; - } - } - - private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) - { - // Write warning once - if (!s_warningWritten.Value && _cmdlet is not null) - { - s_warningWritten.Value = true; - string warningMessage = string.Format( - System.Globalization.CultureInfo.CurrentCulture, - "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.", - _maxDepth); - _cmdlet.WriteWarning(warningMessage); - } - - // Convert to string when depth exceeded - string stringValue = pso.ImmediateBaseObjectIsEmpty - ? LanguagePrimitives.ConvertTo(pso) - : LanguagePrimitives.ConvertTo(obj); - writer.WriteStringValue(stringValue); - } - - private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options) - { - var value = token.Type switch - { - Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => token.ToString() - }; - System.Text.Json.JsonSerializer.Serialize(writer, value, options); - } - - private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) - { - writer.WriteStartArray(); - foreach (var item in enumerable) - { - if (item is null) - { - writer.WriteNullValue(); - } - else - { - var psoItem = PSObject.AsPSObject(item); - // Recursive call - Write will handle depth tracking - Write(writer, psoItem, options); - } - } - - writer.WriteEndArray(); - } - - private static bool IsPrimitiveType(object obj) - { - var type = obj.GetType(); - return type.IsPrimitive - || type.IsEnum - || obj is string - || obj is decimal - || obj is DateTime - || obj is DateTimeOffset - || obj is Guid - || obj is Uri - || obj is BigInteger; - } - - private static bool IsPrimitiveTypeOrNull(object? obj) - { - return obj is null || IsPrimitiveType(obj); - } - - private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options) - { - writer.WriteStartObject(); - - // Serialize dictionary entries - foreach (DictionaryEntry entry in dict) - { - string key = entry.Key?.ToString() ?? string.Empty; - writer.WritePropertyName(key); - WriteValue(writer, entry.Value, options); - } - - // Add PSObject extended properties - AppendPSProperties(writer, pso, options, excludeBaseProperties: true); - - writer.WriteEndObject(); - } - - private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) - { - if (value is null) - { - writer.WriteNullValue(); - } - else if (IsPrimitiveType(value)) - { - System.Text.Json.JsonSerializer.Serialize(writer, value, value.GetType(), options); - } - else - { - // Non-primitive: wrap in PSObject and call Write for depth tracking - var psoValue = PSObject.AsPSObject(value); - Write(writer, psoValue, options); - } - } - - private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) - { - writer.WriteStartObject(); - AppendPSProperties(writer, pso, options, excludeBaseProperties: false); - writer.WriteEndObject(); - } - - private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, bool excludeBaseProperties) - { - var memberTypes = excludeBaseProperties - ? PSMemberViewTypes.Extended - : (PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted); - - var properties = new PSMemberInfoIntegratingCollection( - pso, - PSObject.GetPropertyCollection(memberTypes)); - - foreach (var prop in properties) - { - // Skip properties with JsonIgnore attribute or Hidden attribute - if (ShouldSkipProperty(prop)) - { - continue; - } - - try - { - var value = prop.Value; - writer.WritePropertyName(prop.Name); - - // If maxDepth is 0, convert non-primitive values to string - if (_maxDepth == 0 && value is not null && !IsPrimitiveTypeOrNull(value)) - { - writer.WriteStringValue(value.ToString()); - } - else - { - // Handle null values directly (including AutomationNull) - if (LanguagePrimitives.IsNull(value)) - { - writer.WriteNullValue(); - } - else - { - // Wrap value in PSObject to ensure custom converters are applied - var psoValue = PSObject.AsPSObject(value); - System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); - } - } - } - catch - { - // Skip properties that throw on access - continue; - } - } - } - - private static bool ShouldSkipProperty(PSPropertyInfo prop) - { - // Check for Hidden attribute - if (prop.IsHidden) - { - return true; - } - - // Note: JsonIgnoreAttribute check would require reflection on the underlying member - // which may not be available for all PSPropertyInfo types. For now, we rely on - // IsHidden to filter properties that should not be serialized. - return false; - } - } - - /// - /// JsonConverter for Int64/UInt64 enums to avoid JavaScript precision issues. - /// - internal sealed class JsonConverterInt64Enum : System.Text.Json.Serialization.JsonConverter - { - public override bool CanConvert(Type typeToConvert) - { - if (!typeToConvert.IsEnum) - { - return false; - } - - var underlyingType = Enum.GetUnderlyingType(typeToConvert); - return underlyingType == typeof(long) || underlyingType == typeof(ulong); - } - - public override Enum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - public override void Write(Utf8JsonWriter writer, Enum value, JsonSerializerOptions options) - { - // Convert to string to avoid JavaScript precision issues with large integers - writer.WriteStringValue(value.ToString("D")); - } - } - - /// - /// JsonConverter for NullString to serialize as null. - /// - internal sealed class JsonConverterNullString : System.Text.Json.Serialization.JsonConverter - { - public override System.Management.Automation.Language.NullString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - public override void Write(Utf8JsonWriter writer, System.Management.Automation.Language.NullString value, JsonSerializerOptions options) - { - writer.WriteNullValue(); - } - } - - /// - /// JsonConverter for DBNull to serialize as null. - /// - internal sealed class JsonConverterDBNull : System.Text.Json.Serialization.JsonConverter - { - public override DBNull? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - public override void Write(Utf8JsonWriter writer, DBNull value, JsonSerializerOptions options) - { - writer.WriteNullValue(); - } - } - - /// - /// JsonConverter for BigInteger to serialize as number string. - /// - internal sealed class JsonConverterBigInteger : System.Text.Json.Serialization.JsonConverter - { - public override BigInteger Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializerOptions options) - { - // Write as number string to preserve precision - writer.WriteRawValue(value.ToString(CultureInfo.InvariantCulture)); - } - } - } diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs new file mode 100644 index 00000000000..cae276d3101 --- /dev/null +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -0,0 +1,685 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Internal; +using System.Numerics; +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Unicode; +using System.Threading; + +using Newtonsoft.Json; + +using NewtonsoftStringEscapeHandling = Newtonsoft.Json.StringEscapeHandling; +using StjJsonIgnoreAttribute = System.Text.Json.Serialization.JsonIgnoreAttribute; + +namespace Microsoft.PowerShell.Commands +{ + /// + /// The ConvertTo-Json command (V2 - System.Text.Json implementation). + /// This command converts an object to a Json string representation. + /// + /// + /// This class is shown when PSJsonSerializerV2 experimental feature is enabled. + /// V2 uses System.Text.Json with circular reference detection and unlimited depth by default. + /// + [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Show)] + [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)] + [OutputType(typeof(string))] + public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable + { + /// + /// Gets or sets the InputObject property. + /// + [Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true)] + [AllowNull] + public object? InputObject { get; set; } + + private readonly CancellationTokenSource _cancellationSource = new(); + + /// + /// Gets or sets the Depth property. + /// Default is 64. Maximum allowed depth is 1000 due to System.Text.Json limitations. + /// Use 0 to serialize only top-level properties. + /// + [Parameter] + [ValidateRange(0, 1000)] + public int Depth { get; set; } = 64; + + /// + /// Gets or sets the Compress property. + /// If the Compress property is set to be true, the Json string will + /// be output in the compressed way. Otherwise, the Json string will + /// be output with indentations. + /// + [Parameter] + public SwitchParameter Compress { get; set; } + + /// + /// Gets or sets the EnumsAsStrings property. + /// If the EnumsAsStrings property is set to true, enum values will + /// be converted to their string equivalent. Otherwise, enum values + /// will be converted to their numeric equivalent. + /// + [Parameter] + public SwitchParameter EnumsAsStrings { get; set; } + + /// + /// Gets or sets the AsArray property. + /// If the AsArray property is set to be true, the result JSON string will + /// be returned with surrounding '[', ']' chars. Otherwise, + /// the array symbols will occur only if there is more than one input object. + /// + [Parameter] + public SwitchParameter AsArray { get; set; } + + /// + /// Specifies how strings are escaped when writing JSON text. + /// If the EscapeHandling property is set to EscapeHtml, the result JSON string will + /// be returned with HTML (<, >, &, ', ") and control characters (e.g. newline) are escaped. + /// + [Parameter] + public NewtonsoftStringEscapeHandling EscapeHandling { get; set; } = NewtonsoftStringEscapeHandling.Default; + + /// + /// Gets or sets custom JsonSerializerOptions for advanced scenarios. + /// When specified, bypasses V1-compatible processing and uses STJ directly. + /// Note: ETS properties will not be serialized in this mode. + /// + [Parameter] + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// IDisposable implementation, dispose of any disposable resources created by the cmdlet. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Implementation of IDisposable for both manual Dispose() and finalizer-called disposal of resources. + /// + /// + /// Specified as true when Dispose() was called, false if this is called from the finalizer. + /// + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _cancellationSource.Dispose(); + } + } + + private readonly List _inputObjects = new(); + + /// + /// Caching the input objects for the command. + /// + protected override void ProcessRecord() + { + _inputObjects.Add(InputObject); + } + + /// + /// Do the conversion to json and write output. + /// + protected override void EndProcessing() + { + if (_inputObjects.Count > 0) + { + object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (_inputObjects.ToArray() as object) : _inputObjects[0]; + + string? output; + + if (JsonSerializerOptions is not null) + { + // Direct STJ mode - bypasses V1-compatible processing + // Custom JsonConverters will work, but ETS properties are not serialized + try + { + // Unwrap PSObject to get the base object for direct STJ serialization + var objToSerialize = objectToProcess is PSObject pso ? pso.BaseObject : objectToProcess; + output = System.Text.Json.JsonSerializer.Serialize(objToSerialize, JsonSerializerOptions); + } + catch (OperationCanceledException) + { + output = null; + } + } + else + { + // V1-compatible mode + output = SystemTextJsonSerializer.ConvertToJson( + objectToProcess, + Depth, + EnumsAsStrings.IsPresent, + Compress.IsPresent, + EscapeHandling, + this, + _cancellationSource.Token); + } + + // null is returned only if the pipeline is stopping (e.g. ctrl+c is signaled). + // in that case, we shouldn't write the null to the output pipe. + if (output is not null) + { + WriteObject(output); + } + } + } + + /// + /// Process the Ctrl+C signal. + /// + protected override void StopProcessing() + { + _cancellationSource.Cancel(); + } + } + + /// + /// Provides JSON serialization using System.Text.Json with PowerShell-specific handling. + /// + internal static class SystemTextJsonSerializer + { + /// + /// Convert an object to JSON string using System.Text.Json. + /// + public static string? ConvertToJson( + object? objectToProcess, + int maxDepth, + bool enumsAsStrings, + bool compressOutput, + NewtonsoftStringEscapeHandling stringEscapeHandling, + PSCmdlet? cmdlet, + CancellationToken cancellationToken) + { + if (objectToProcess is null) + { + return "null"; + } + + try + { + // Reset depth tracking for this serialization + JsonConverterPSObject.ResetDepthTracking(); + + var options = new JsonSerializerOptions() + { + WriteIndented = !compressOutput, + // Use maximum allowed depth to avoid System.Text.Json exceptions + // Actual depth limiting is handled by JsonConverterPSObject + MaxDepth = 1000, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Encoder = GetEncoder(stringEscapeHandling), + ReferenceHandler = ReferenceHandler.IgnoreCycles, + }; + + if (enumsAsStrings) + { + options.Converters.Add(new JsonStringEnumConverter()); + } + + // Add custom converters for PowerShell-specific types + options.Converters.Add(new JsonConverterInt64Enum()); + options.Converters.Add(new JsonConverterBigInteger()); + options.Converters.Add(new JsonConverterNullString()); + options.Converters.Add(new JsonConverterDBNull()); + options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth)); + + // Handle JObject specially to avoid IEnumerable serialization + if (objectToProcess is Newtonsoft.Json.Linq.JObject jObj) + { + // Serialize JObject directly using our custom logic + using var stream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = !compressOutput, Encoder = GetEncoder(stringEscapeHandling) })) + { + writer.WriteStartObject(); + foreach (var prop in jObj.Properties()) + { + writer.WritePropertyName(prop.Name); + var value = prop.Value.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => prop.Value.ToString() + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + writer.WriteEndObject(); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + // Wrap in PSObject to ensure ETS properties are preserved + var pso = PSObject.AsPSObject(objectToProcess); + return System.Text.Json.JsonSerializer.Serialize(pso, typeof(PSObject), options); + } + catch (OperationCanceledException) + { + return null; + } + } + + private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escapeHandling) + { + return escapeHandling switch + { + NewtonsoftStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + NewtonsoftStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, + NewtonsoftStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), + _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + } + } + + /// + /// Custom JsonConverter for PSObject that handles PowerShell-specific serialization. + /// + internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.JsonConverter + { + private readonly PSCmdlet? _cmdlet; + private readonly int _maxDepth; + + // Depth tracking across recursive calls + private static readonly AsyncLocal s_currentDepth = new(); + private static readonly AsyncLocal s_warningWritten = new(); + + /// + /// Reset depth tracking for a new serialization operation. + /// + public static void ResetDepthTracking() + { + s_currentDepth.Value = 0; + s_warningWritten.Value = false; + } + + public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) + { + _cmdlet = cmdlet; + _maxDepth = maxDepth; + } + + public override PSObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + if (LanguagePrimitives.IsNull(pso)) + { + writer.WriteNullValue(); + return; + } + + var obj = pso.BaseObject; + + int currentDepth = s_currentDepth.Value; + + // Handle special types - check for null-like objects (no depth increment needed) + if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) + { + // Check if PSObject has Extended/Adapted properties + bool hasETSProps = pso.Properties.Match("*", PSMemberTypes.NoteProperty | PSMemberTypes.AliasProperty).Count > 0; + if (hasETSProps) + { + s_currentDepth.Value = currentDepth + 1; + try + { + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + AppendPSProperties(writer, pso, options, excludeBaseProperties: true); + writer.WriteEndObject(); + } + finally + { + s_currentDepth.Value = currentDepth; + } + } + else + { + writer.WriteNullValue(); + } + + return; + } + + // Handle Newtonsoft.Json.Linq.JObject by converting properties manually + if (obj is Newtonsoft.Json.Linq.JObject jObject) + { + s_currentDepth.Value = currentDepth + 1; + try + { + writer.WriteStartObject(); + foreach (var prop in jObject.Properties()) + { + writer.WritePropertyName(prop.Name); + WriteJTokenValue(writer, prop.Value, options); + } + + writer.WriteEndObject(); + } + finally + { + s_currentDepth.Value = currentDepth; + } + + return; + } + + // If PSObject wraps a primitive type, serialize the base object directly (no depth increment) + if (IsPrimitiveType(obj)) + { + System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); + return; + } + + // Check depth limit for complex types only (after primitive check) + if (currentDepth > _maxDepth) + { + WriteDepthExceeded(writer, pso, obj); + return; + } + // For dictionaries, collections, and custom objects - increment depth + s_currentDepth.Value = currentDepth + 1; + try + { + if (obj is IDictionary dict) + { + SerializeDictionary(writer, pso, dict, options); + } + else if (obj is IEnumerable enumerable and not string) + { + SerializeEnumerable(writer, enumerable, options); + } + else + { + // For custom objects, serialize as dictionary with properties + SerializeAsObject(writer, pso, options); + } + } + finally + { + s_currentDepth.Value = currentDepth; + } + } + + private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) + { + // Write warning once + if (!s_warningWritten.Value && _cmdlet is not null) + { + s_warningWritten.Value = true; + string warningMessage = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.", + _maxDepth); + _cmdlet.WriteWarning(warningMessage); + } + + // Convert to string when depth exceeded + string stringValue = pso.ImmediateBaseObjectIsEmpty + ? LanguagePrimitives.ConvertTo(pso) + : LanguagePrimitives.ConvertTo(obj); + writer.WriteStringValue(stringValue); + } + + private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options) + { + var value = token.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => token.ToString() + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + + private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var item in enumerable) + { + if (item is null) + { + writer.WriteNullValue(); + } + else + { + var psoItem = PSObject.AsPSObject(item); + // Recursive call - Write will handle depth tracking + Write(writer, psoItem, options); + } + } + + writer.WriteEndArray(); + } + + private static bool IsPrimitiveType(object obj) + { + var type = obj.GetType(); + return type.IsPrimitive + || type.IsEnum + || obj is string + || obj is decimal + || obj is DateTime + || obj is DateTimeOffset + || obj is Guid + || obj is Uri + || obj is BigInteger; + } + + private static bool IsPrimitiveTypeOrNull(object? obj) + { + return obj is null || IsPrimitiveType(obj); + } + + private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + // Serialize dictionary entries + foreach (DictionaryEntry entry in dict) + { + string key = entry.Key?.ToString() ?? string.Empty; + writer.WritePropertyName(key); + WriteValue(writer, entry.Value, options); + } + + // Add PSObject extended properties + AppendPSProperties(writer, pso, options, excludeBaseProperties: true); + + writer.WriteEndObject(); + } + + private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else if (IsPrimitiveType(value)) + { + System.Text.Json.JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + else + { + // Non-primitive: wrap in PSObject and call Write for depth tracking + var psoValue = PSObject.AsPSObject(value); + Write(writer, psoValue, options); + } + } + + private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + writer.WriteStartObject(); + AppendPSProperties(writer, pso, options, excludeBaseProperties: false); + writer.WriteEndObject(); + } + + private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, bool excludeBaseProperties) + { + var memberTypes = excludeBaseProperties + ? PSMemberViewTypes.Extended + : (PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted); + + var properties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(memberTypes)); + + foreach (var prop in properties) + { + // Skip properties with JsonIgnore attribute or Hidden attribute + if (ShouldSkipProperty(prop)) + { + continue; + } + + try + { + var value = prop.Value; + writer.WritePropertyName(prop.Name); + + // If maxDepth is 0, convert non-primitive values to string + if (_maxDepth == 0 && value is not null && !IsPrimitiveTypeOrNull(value)) + { + writer.WriteStringValue(value.ToString()); + } + else + { + // Handle null values directly (including AutomationNull) + if (LanguagePrimitives.IsNull(value)) + { + writer.WriteNullValue(); + } + else + { + // Wrap value in PSObject to ensure custom converters are applied + var psoValue = PSObject.AsPSObject(value); + System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); + } + } + } + catch + { + // Skip properties that throw on access + continue; + } + } + } + + private static bool ShouldSkipProperty(PSPropertyInfo prop) + { + // Check for Hidden attribute + if (prop.IsHidden) + { + return true; + } + + // Note: JsonIgnoreAttribute check would require reflection on the underlying member + // which may not be available for all PSPropertyInfo types. For now, we rely on + // IsHidden to filter properties that should not be serialized. + return false; + } + } + + /// + /// JsonConverter for Int64/UInt64 enums to avoid JavaScript precision issues. + /// + internal sealed class JsonConverterInt64Enum : System.Text.Json.Serialization.JsonConverter + { + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsEnum) + { + return false; + } + + var underlyingType = Enum.GetUnderlyingType(typeToConvert); + return underlyingType == typeof(long) || underlyingType == typeof(ulong); + } + + public override Enum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, Enum value, JsonSerializerOptions options) + { + // Convert to string to avoid JavaScript precision issues with large integers + writer.WriteStringValue(value.ToString("D")); + } + } + + /// + /// JsonConverter for NullString to serialize as null. + /// + internal sealed class JsonConverterNullString : System.Text.Json.Serialization.JsonConverter + { + public override System.Management.Automation.Language.NullString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, System.Management.Automation.Language.NullString value, JsonSerializerOptions options) + { + writer.WriteNullValue(); + } + } + + /// + /// JsonConverter for DBNull to serialize as null. + /// + internal sealed class JsonConverterDBNull : System.Text.Json.Serialization.JsonConverter + { + public override DBNull? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, DBNull value, JsonSerializerOptions options) + { + writer.WriteNullValue(); + } + } + + /// + /// JsonConverter for BigInteger to serialize as number string. + /// + internal sealed class JsonConverterBigInteger : System.Text.Json.Serialization.JsonConverter + { + public override BigInteger Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializerOptions options) + { + // Write as number string to preserve precision + writer.WriteRawValue(value.ToString(CultureInfo.InvariantCulture)); + } + } + +} From ef5bf6d4c55d9a72fb3121b0339bd402dffa611d Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sat, 20 Dec 2025 10:51:31 +0900 Subject: [PATCH 13/42] Replace AsyncLocal depth tracking with writer.CurrentDepth for simpler and more robust implementation --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 72 ++++++------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index cae276d3101..1557e28195b 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -298,8 +298,7 @@ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.Jso private readonly PSCmdlet? _cmdlet; private readonly int _maxDepth; - // Depth tracking across recursive calls - private static readonly AsyncLocal s_currentDepth = new(); + // Warning tracking private static readonly AsyncLocal s_warningWritten = new(); /// @@ -307,7 +306,6 @@ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.Jso /// public static void ResetDepthTracking() { - s_currentDepth.Value = 0; s_warningWritten.Value = false; } @@ -332,7 +330,7 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp var obj = pso.BaseObject; - int currentDepth = s_currentDepth.Value; + int currentDepth = writer.CurrentDepth; // Handle special types - check for null-like objects (no depth increment needed) if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) @@ -341,19 +339,11 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp bool hasETSProps = pso.Properties.Match("*", PSMemberTypes.NoteProperty | PSMemberTypes.AliasProperty).Count > 0; if (hasETSProps) { - s_currentDepth.Value = currentDepth + 1; - try - { - writer.WriteStartObject(); - writer.WritePropertyName("value"); - writer.WriteNullValue(); - AppendPSProperties(writer, pso, options, excludeBaseProperties: true); - writer.WriteEndObject(); - } - finally - { - s_currentDepth.Value = currentDepth; - } + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + AppendPSProperties(writer, pso, options, excludeBaseProperties: true); + writer.WriteEndObject(); } else { @@ -366,23 +356,15 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp // Handle Newtonsoft.Json.Linq.JObject by converting properties manually if (obj is Newtonsoft.Json.Linq.JObject jObject) { - s_currentDepth.Value = currentDepth + 1; - try + writer.WriteStartObject(); + foreach (var prop in jObject.Properties()) { - writer.WriteStartObject(); - foreach (var prop in jObject.Properties()) - { - writer.WritePropertyName(prop.Name); - WriteJTokenValue(writer, prop.Value, options); - } - - writer.WriteEndObject(); - } - finally - { - s_currentDepth.Value = currentDepth; + writer.WritePropertyName(prop.Name); + WriteJTokenValue(writer, prop.Value, options); } + writer.WriteEndObject(); + return; } @@ -399,27 +381,19 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp WriteDepthExceeded(writer, pso, obj); return; } - // For dictionaries, collections, and custom objects - increment depth - s_currentDepth.Value = currentDepth + 1; - try + // For dictionaries, collections, and custom objects + if (obj is IDictionary dict) { - if (obj is IDictionary dict) - { - SerializeDictionary(writer, pso, dict, options); - } - else if (obj is IEnumerable enumerable and not string) - { - SerializeEnumerable(writer, enumerable, options); - } - else - { - // For custom objects, serialize as dictionary with properties - SerializeAsObject(writer, pso, options); - } + SerializeDictionary(writer, pso, dict, options); } - finally + else if (obj is IEnumerable enumerable and not string) + { + SerializeEnumerable(writer, enumerable, options); + } + else { - s_currentDepth.Value = currentDepth; + // For custom objects, serialize as dictionary with properties + SerializeAsObject(writer, pso, options); } } From e211385de7829565b1f55bb700025fe7872c4be6 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sat, 20 Dec 2025 11:08:40 +0900 Subject: [PATCH 14/42] Remove AsyncLocal completely by using instance variable for warning tracking --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 1557e28195b..46433702b7e 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -215,10 +215,7 @@ internal static class SystemTextJsonSerializer try { - // Reset depth tracking for this serialization - JsonConverterPSObject.ResetDepthTracking(); - - var options = new JsonSerializerOptions() + var options = new JsonSerializerOptions() { WriteIndented = !compressOutput, // Use maximum allowed depth to avoid System.Text.Json exceptions @@ -298,16 +295,8 @@ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.Jso private readonly PSCmdlet? _cmdlet; private readonly int _maxDepth; - // Warning tracking - private static readonly AsyncLocal s_warningWritten = new(); - - /// - /// Reset depth tracking for a new serialization operation. - /// - public static void ResetDepthTracking() - { - s_warningWritten.Value = false; - } + // Warning tracking (instance variable, reset per serialization) + private bool _warningWritten; public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) { @@ -400,9 +389,9 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) { // Write warning once - if (!s_warningWritten.Value && _cmdlet is not null) + if (!_warningWritten && _cmdlet is not null) { - s_warningWritten.Value = true; + _warningWritten = true; string warningMessage = string.Format( System.Globalization.CultureInfo.CurrentCulture, "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.", From e03574c364b9af2c501d63d9cc3487e312d502fa Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sat, 20 Dec 2025 11:37:09 +0900 Subject: [PATCH 15/42] Apply -Depth parameter to -JsonSerializerOptions mode when MaxDepth is not explicitly set --- .../utility/WebCmdlet/ConvertToJsonCommandV2.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 46433702b7e..a56872be72a 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -151,9 +151,18 @@ protected override void EndProcessing() // Custom JsonConverters will work, but ETS properties are not serialized try { + var options = JsonSerializerOptions; + + // If user hasn't set MaxDepth, apply the -Depth parameter + if (options.MaxDepth == 0) + { + options = new JsonSerializerOptions(options); + options.MaxDepth = Depth; + } + // Unwrap PSObject to get the base object for direct STJ serialization var objToSerialize = objectToProcess is PSObject pso ? pso.BaseObject : objectToProcess; - output = System.Text.Json.JsonSerializer.Serialize(objToSerialize, JsonSerializerOptions); + output = System.Text.Json.JsonSerializer.Serialize(objToSerialize, options); } catch (OperationCanceledException) { From df6dac2c8f44bc49acaa592eb08529d6dcc24d5c Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sat, 20 Dec 2025 12:15:00 +0900 Subject: [PATCH 16/42] Remove unused ReferenceHandler.IgnoreCycles and fix misleading comment --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index a56872be72a..287b6f4b4b2 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -33,7 +33,7 @@ namespace Microsoft.PowerShell.Commands /// /// /// This class is shown when PSJsonSerializerV2 experimental feature is enabled. - /// V2 uses System.Text.Json with circular reference detection and unlimited depth by default. + /// V2 uses System.Text.Json with V1-compatible depth handling. /// [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Show)] [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)] @@ -51,12 +51,12 @@ public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable /// /// Gets or sets the Depth property. - /// Default is 64. Maximum allowed depth is 1000 due to System.Text.Json limitations. + /// Default is 2. Maximum allowed is 1000. /// Use 0 to serialize only top-level properties. /// [Parameter] [ValidateRange(0, 1000)] - public int Depth { get; set; } = 64; + public int Depth { get; set; } = 2; /// /// Gets or sets the Compress property. @@ -232,7 +232,6 @@ internal static class SystemTextJsonSerializer MaxDepth = 1000, DefaultIgnoreCondition = JsonIgnoreCondition.Never, Encoder = GetEncoder(stringEscapeHandling), - ReferenceHandler = ReferenceHandler.IgnoreCycles, }; if (enumsAsStrings) From 207f9e5a061a31b202ba4037f5b69c9e04fdc453 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sat, 20 Dec 2025 12:24:27 +0900 Subject: [PATCH 17/42] Make -Depth and -JsonSerializerOptions mutually exclusive --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 287b6f4b4b2..52b5136c3d2 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -126,6 +126,21 @@ protected virtual void Dispose(bool disposing) private readonly List _inputObjects = new(); + /// + /// Validate parameter combinations. + /// + protected override void BeginProcessing() + { + if (JsonSerializerOptions is not null && MyInvocation.BoundParameters.ContainsKey("Depth")) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentException("The -Depth and -JsonSerializerOptions parameters cannot be used together. Use JsonSerializerOptions.MaxDepth to control depth."), + "DepthAndJsonSerializerOptionsAreMutuallyExclusive", + ErrorCategory.InvalidArgument, + null)); + } + } + /// /// Caching the input objects for the command. /// @@ -149,20 +164,12 @@ protected override void EndProcessing() { // Direct STJ mode - bypasses V1-compatible processing // Custom JsonConverters will work, but ETS properties are not serialized + // Depth is controlled by JsonSerializerOptions.MaxDepth (default 64) try { - var options = JsonSerializerOptions; - - // If user hasn't set MaxDepth, apply the -Depth parameter - if (options.MaxDepth == 0) - { - options = new JsonSerializerOptions(options); - options.MaxDepth = Depth; - } - // Unwrap PSObject to get the base object for direct STJ serialization var objToSerialize = objectToProcess is PSObject pso ? pso.BaseObject : objectToProcess; - output = System.Text.Json.JsonSerializer.Serialize(objToSerialize, options); + output = System.Text.Json.JsonSerializer.Serialize(objToSerialize, JsonSerializerOptions); } catch (OperationCanceledException) { From d7ac8b9efe08c3d9fe28a699c195d4c6812ad17e Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sun, 21 Dec 2025 18:04:14 +0900 Subject: [PATCH 18/42] Remove -JsonSerializerOptions parameter to focus on V1 compatibility --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 61 ++----------- ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 87 ------------------- 2 files changed, 8 insertions(+), 140 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 52b5136c3d2..b0069e227d7 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -93,14 +93,6 @@ public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable [Parameter] public NewtonsoftStringEscapeHandling EscapeHandling { get; set; } = NewtonsoftStringEscapeHandling.Default; - /// - /// Gets or sets custom JsonSerializerOptions for advanced scenarios. - /// When specified, bypasses V1-compatible processing and uses STJ directly. - /// Note: ETS properties will not be serialized in this mode. - /// - [Parameter] - public JsonSerializerOptions? JsonSerializerOptions { get; set; } - /// /// IDisposable implementation, dispose of any disposable resources created by the cmdlet. /// @@ -126,21 +118,6 @@ protected virtual void Dispose(bool disposing) private readonly List _inputObjects = new(); - /// - /// Validate parameter combinations. - /// - protected override void BeginProcessing() - { - if (JsonSerializerOptions is not null && MyInvocation.BoundParameters.ContainsKey("Depth")) - { - ThrowTerminatingError(new ErrorRecord( - new ArgumentException("The -Depth and -JsonSerializerOptions parameters cannot be used together. Use JsonSerializerOptions.MaxDepth to control depth."), - "DepthAndJsonSerializerOptionsAreMutuallyExclusive", - ErrorCategory.InvalidArgument, - null)); - } - } - /// /// Caching the input objects for the command. /// @@ -158,36 +135,14 @@ protected override void EndProcessing() { object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (_inputObjects.ToArray() as object) : _inputObjects[0]; - string? output; - - if (JsonSerializerOptions is not null) - { - // Direct STJ mode - bypasses V1-compatible processing - // Custom JsonConverters will work, but ETS properties are not serialized - // Depth is controlled by JsonSerializerOptions.MaxDepth (default 64) - try - { - // Unwrap PSObject to get the base object for direct STJ serialization - var objToSerialize = objectToProcess is PSObject pso ? pso.BaseObject : objectToProcess; - output = System.Text.Json.JsonSerializer.Serialize(objToSerialize, JsonSerializerOptions); - } - catch (OperationCanceledException) - { - output = null; - } - } - else - { - // V1-compatible mode - output = SystemTextJsonSerializer.ConvertToJson( - objectToProcess, - Depth, - EnumsAsStrings.IsPresent, - Compress.IsPresent, - EscapeHandling, - this, - _cancellationSource.Token); - } + string? output = SystemTextJsonSerializer.ConvertToJson( + objectToProcess, + Depth, + EnumsAsStrings.IsPresent, + Compress.IsPresent, + EscapeHandling, + this, + _cancellationSource.Token); // null is returned only if the pipeline is stopping (e.g. ctrl+c is signaled). // in that case, we shouldn't write the null to the output pipe. diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index 1a2345dc38b..e458237463b 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -7,89 +7,11 @@ BeforeDiscovery { Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { Context "Default values and limits" { - It "V2: Default depth should be unlimited" -Skip:(-not $script:isV2Enabled) { - # Create a 200-level deep object - should work with unlimited default depth - $obj = @{ level = 0 } - for ($i = 1; $i -lt 200; $i++) { - $obj = @{ level = $i; child = $obj } - } - # This should work without truncation at unlimited default depth - $json = $obj | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Match '"level":199' - $warn | Should -BeNullOrEmpty - } - - It "V2: Large depth values should be allowed" -Skip:(-not $script:isV2Enabled) { - { ConvertTo-Json -InputObject @{a=1} -Depth 10000 } | Should -Not -Throw - } - - It "V2: Depth 0 or negative should throw" -Skip:(-not $script:isV2Enabled) { - { ConvertTo-Json -InputObject @{a=1} -Depth 0 } | Should -Throw - { ConvertTo-Json -InputObject @{a=1} -Depth -1 } | Should -Throw - { ConvertTo-Json -InputObject @{a=1} -Depth -2 } | Should -Throw - } - - It "V2: Minimum depth of 1 should work" -Skip:(-not $script:isV2Enabled) { - $obj = @{ a = @{ b = 1 } } - $json = $obj | ConvertTo-Json -Depth 1 -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Match '"a":' - $warn | Should -Not -BeNullOrEmpty # depth exceeded warning - } - It "Legacy: Depth over 100 should throw when V2 is disabled" -Skip:$script:isV2Enabled { { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw } } - Context "Circular reference detection" { - It "V2: Should detect self-referencing object" -Skip:(-not $script:isV2Enabled) { - $obj = [pscustomobject]@{ Name = "Test"; Self = $null } - $obj.Self = $obj - $json = $obj | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Not -BeNullOrEmpty - $warn | Should -Not -BeNullOrEmpty - $warn | Should -Match 'Circular reference' - } - - It "V2: Should detect circular reference in nested objects" -Skip:(-not $script:isV2Enabled) { - $parent = [pscustomobject]@{ Name = "Parent"; Child = $null } - $child = [pscustomobject]@{ Name = "Child"; Parent = $null } - $parent.Child = $child - $child.Parent = $parent - $json = $parent | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Not -BeNullOrEmpty - $warn | Should -Not -BeNullOrEmpty - $warn | Should -Match 'Circular reference' - } - - It "V2: Should detect circular reference in hashtable" -Skip:(-not $script:isV2Enabled) { - $hash = @{ Name = "Test" } - $hash.Self = $hash - $json = $hash | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Not -BeNullOrEmpty - $warn | Should -Not -BeNullOrEmpty - $warn | Should -Match 'Circular reference' - } - - It "V2: Should detect circular reference in array" -Skip:(-not $script:isV2Enabled) { - $arr = @(1, 2, $null) - $arr[2] = $arr - $json = ConvertTo-Json -InputObject $arr -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Not -BeNullOrEmpty - $warn | Should -Not -BeNullOrEmpty - $warn | Should -Match 'Circular reference' - } - - It "V2: Should handle same object appearing multiple times (not circular)" -Skip:(-not $script:isV2Enabled) { - $shared = @{ value = 42 } - $obj = @{ first = $shared; second = $shared } - # Same object appearing in different branches is fine, not circular - $json = $obj | ConvertTo-Json -Compress -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Match '"value":42' - # Note: This may or may not produce a warning depending on implementation - } - } - Context "Depth exceeded warning" { It "V2: Should output warning when depth is exceeded" -Skip:(-not $script:isV2Enabled) { $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } @@ -97,15 +19,6 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { $json | Should -Not -BeNullOrEmpty $warn | Should -Not -BeNullOrEmpty } - - It "V2: Should convert deep objects to string when depth exceeded" -Skip:(-not $script:isV2Enabled) { - $inner = [pscustomobject]@{ value = "deep" } - $outer = [pscustomobject]@{ child = $inner } - $json = $outer | ConvertTo-Json -Depth 1 -Compress -WarningVariable warn -WarningAction SilentlyContinue - # At depth 1, child should be converted to string - $json | Should -Match '"child":' - $warn | Should -Not -BeNullOrEmpty - } } Context "Non-string dictionary keys" { From 1e096a81bccacccb6ed34eeeecdf7a986ce39055 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Mon, 22 Dec 2025 09:17:46 +0900 Subject: [PATCH 19/42] Reorganize tests for V1/V2 compatibility and update Guid test expectation --- ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 147 +++++++++--------- 1 file changed, 76 insertions(+), 71 deletions(-) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index e458237463b..5fc352ab85b 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -6,96 +6,44 @@ BeforeDiscovery { } Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { - Context "Default values and limits" { - It "Legacy: Depth over 100 should throw when V2 is disabled" -Skip:$script:isV2Enabled { - { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw - } - } - - Context "Depth exceeded warning" { - It "V2: Should output warning when depth is exceeded" -Skip:(-not $script:isV2Enabled) { - $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } - $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Not -BeNullOrEmpty - $warn | Should -Not -BeNullOrEmpty - } - } - - Context "Non-string dictionary keys" { - It "V2: Should serialize dictionary with integer keys" -Skip:(-not $script:isV2Enabled) { - $dict = @{ 1 = "one"; 2 = "two" } - $json = $dict | ConvertTo-Json -Compress - $json | Should -Match '"1":\s*"one"' - $json | Should -Match '"2":\s*"two"' - } - - It "V2: Should serialize Exception.Data with non-string keys" -Skip:(-not $script:isV2Enabled) { - $ex = [System.Exception]::new("test") - $ex.Data.Add(1, "value1") - $ex.Data.Add("key", "value2") - { $ex | ConvertTo-Json -Depth 1 } | Should -Not -Throw - } - } - - Context "JsonIgnoreAttribute and HiddenAttribute" { - It "V2: Should not serialize hidden properties in PowerShell class" -Skip:(-not $script:isV2Enabled) { - class TestHiddenClass { - [string] $Visible - hidden [string] $Hidden - } - $obj = [TestHiddenClass]::new() - $obj.Visible = "yes" - $obj.Hidden = "no" - $json = $obj | ConvertTo-Json -Compress - $json | Should -Match 'Visible' - $json | Should -Not -Match 'Hidden' - } - } - - Context "Special types" { - It "V2: Should serialize Uri correctly" -Skip:(-not $script:isV2Enabled) { + Context "V1/V2 compatible - Special types" { + It "Should serialize Uri correctly" { $uri = [uri]"https://example.com/path" $json = $uri | ConvertTo-Json -Compress $json | Should -BeExactly '"https://example.com/path"' } - It "V2: Should serialize Guid correctly" -Skip:(-not $script:isV2Enabled) { - $guid = [guid]"12345678-1234-1234-1234-123456789abc" - $json = ConvertTo-Json -InputObject $guid -Compress - $json | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' - } - - It "V2: Should serialize BigInteger correctly" -Skip:(-not $script:isV2Enabled) { + It "Should serialize BigInteger correctly" { $big = [System.Numerics.BigInteger]::Parse("123456789012345678901234567890") $json = ConvertTo-Json -InputObject $big -Compress $json | Should -BeExactly '123456789012345678901234567890' } - It "V2: Should serialize enums as numbers by default" -Skip:(-not $script:isV2Enabled) { + It "Should serialize enums as numbers by default" { $json = [System.DayOfWeek]::Monday | ConvertTo-Json $json | Should -BeExactly '1' } - It "V2: Should serialize enums as strings with -EnumsAsStrings" -Skip:(-not $script:isV2Enabled) { + It "Should serialize enums as strings with -EnumsAsStrings" { $json = [System.DayOfWeek]::Monday | ConvertTo-Json -EnumsAsStrings $json | Should -BeExactly '"Monday"' } } - Context "Null handling" { - It "V2: Should serialize null correctly" -Skip:(-not $script:isV2Enabled) { + Context "V1/V2 compatible - Null handling" { + It "Should serialize null correctly" { $null | ConvertTo-Json | Should -BeExactly 'null' } - It "V2: Should serialize DBNull as null" -Skip:(-not $script:isV2Enabled) { + It "Should serialize DBNull as null" { [System.DBNull]::Value | ConvertTo-Json | Should -BeExactly 'null' } - It "V2: Should serialize NullString as null" -Skip:(-not $script:isV2Enabled) { + It "Should serialize NullString as null" { [NullString]::Value | ConvertTo-Json | Should -BeExactly 'null' } - It "V2: Should handle ETS properties on DBNull" -Skip:(-not $script:isV2Enabled) { + It "Should handle ETS properties on DBNull" { try { $p = Add-Member -InputObject ([System.DBNull]::Value) -MemberType NoteProperty -Name testprop -Value 'testvalue' -PassThru $json = $p | ConvertTo-Json -Compress @@ -108,20 +56,20 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } } - Context "Collections" { - It "V2: Should serialize arrays correctly" -Skip:(-not $script:isV2Enabled) { + Context "V1/V2 compatible - Collections" { + It "Should serialize arrays correctly" { $arr = @(1, 2, 3) $json = $arr | ConvertTo-Json -Compress $json | Should -BeExactly '[1,2,3]' } - It "V2: Should serialize hashtable correctly" -Skip:(-not $script:isV2Enabled) { + It "Should serialize hashtable correctly" { $hash = [ordered]@{ a = 1; b = 2 } $json = $hash | ConvertTo-Json -Compress $json | Should -BeExactly '{"a":1,"b":2}' } - It "V2: Should serialize nested objects correctly" -Skip:(-not $script:isV2Enabled) { + It "Should serialize nested objects correctly" { $obj = [pscustomobject]@{ name = "test" child = [pscustomobject]@{ @@ -133,26 +81,26 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } } - Context "EscapeHandling" { - It "V2: Should not escape by default" -Skip:(-not $script:isV2Enabled) { + Context "V1/V2 compatible - EscapeHandling" { + It "Should not escape by default" { $json = @{ text = "<>&" } | ConvertTo-Json -Compress $json | Should -BeExactly '{"text":"<>&"}' } - It "V2: Should escape HTML with -EscapeHandling EscapeHtml" -Skip:(-not $script:isV2Enabled) { + It "Should escape HTML with -EscapeHandling EscapeHtml" { $json = @{ text = "<>&" } | ConvertTo-Json -Compress -EscapeHandling EscapeHtml $json | Should -Match '\\u003C' $json | Should -Match '\\u003E' $json | Should -Match '\\u0026' } - It "V2: Should escape non-ASCII with -EscapeHandling EscapeNonAscii" -Skip:(-not $script:isV2Enabled) { + It "Should escape non-ASCII with -EscapeHandling EscapeNonAscii" { $json = @{ text = "日本語" } | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii $json | Should -Match '\\u' } } - Context "Backward compatibility" { + Context "V1/V2 compatible - Backward compatibility" { It "Should still support Newtonsoft JObject" { $jobj = New-Object Newtonsoft.Json.Linq.JObject $jobj.Add("key", [Newtonsoft.Json.Linq.JToken]::FromObject("value")) @@ -181,4 +129,61 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { $json | Should -Match '^\[.*\]$' } } + + Context "V1 only - Legacy limits" { + It "Depth over 100 should throw when V2 is disabled" -Skip:$script:isV2Enabled { + { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw + } + } + + Context "V2 only - Depth exceeded warning" { + It "Should output warning when depth is exceeded" -Skip:(-not $script:isV2Enabled) { + $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } + $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + } + } + + Context "V2 only - Non-string dictionary keys" { + It "Should serialize dictionary with integer keys" -Skip:(-not $script:isV2Enabled) { + $dict = @{ 1 = "one"; 2 = "two" } + $json = $dict | ConvertTo-Json -Compress + $json | Should -Match '"1":\s*"one"' + $json | Should -Match '"2":\s*"two"' + } + + It "Should serialize Exception.Data with non-string keys" -Skip:(-not $script:isV2Enabled) { + $ex = [System.Exception]::new("test") + $ex.Data.Add(1, "value1") + $ex.Data.Add("key", "value2") + { $ex | ConvertTo-Json -Depth 1 } | Should -Not -Throw + } + } + + Context "V2 only - HiddenAttribute" { + It "Should not serialize hidden properties in PowerShell class" -Skip:(-not $script:isV2Enabled) { + class TestHiddenClass { + [string] $Visible + hidden [string] $Hidden + } + $obj = [TestHiddenClass]::new() + $obj.Visible = "yes" + $obj.Hidden = "no" + $json = $obj | ConvertTo-Json -Compress + $json | Should -Match 'Visible' + $json | Should -Not -Match 'Hidden' + } + } + + Context "V2 only - Guid serialization" { + It "Should serialize Guid as string consistently" -Skip:(-not $script:isV2Enabled) { + $guid = [guid]"12345678-1234-1234-1234-123456789abc" + # Both methods should produce the same string output (unlike V1 which is inconsistent) + $jsonPipeline = $guid | ConvertTo-Json -Compress + $jsonInputObject = ConvertTo-Json -InputObject $guid -Compress + $jsonPipeline | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' + $jsonInputObject | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' + } + } } From e6e64b304e1cc1e285f7a3802516e256f02757fb Mon Sep 17 00:00:00 2001 From: yotsuda Date: Mon, 22 Dec 2025 09:33:02 +0900 Subject: [PATCH 20/42] Remove unused resources and update experimental feature description --- .gitignore | 5 ----- .../resources/WebCmdletStrings.resx | 6 ------ .../engine/ExperimentalFeature/ExperimentalFeature.cs | 2 +- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index bf5c6050677..f115e61e22d 100644 --- a/.gitignore +++ b/.gitignore @@ -121,8 +121,3 @@ tmp/* # Ignore CTRF report files crtf/* -# Work in progress files -work_progress.md -*_test*.ps1 -*_test*.json -*_test*.txt diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index e3dd4090bc5..cb080d37012 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -246,12 +246,6 @@ Resulting JSON is truncated as serialization has exceeded the set depth of {0}. - - The value {0} is not valid for the Depth parameter. The valid range is 0 to {1}. To use higher values, enable the PSJsonSerializerV2 experimental feature. - - - The value {0} is not valid for the Depth parameter. The value must be -1 (unlimited) or a non-negative number. - The WebSession properties were changed between requests forcing all HTTP connections in the session to be recreated. diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 95675a76889..c3098dee703 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -114,7 +114,7 @@ static ExperimentalFeature() ), new ExperimentalFeature( name: PSJsonSerializerV2, - description: "Use System.Text.Json with improved defaults for ConvertTo-Json: Depth default 64 (was 2), no upper limit (-1 for unlimited)." + description: "Use System.Text.Json for ConvertTo-Json with V1-compatible behavior and support for non-string dictionary keys." ), new ExperimentalFeature( name: PSProfileDSCResource, From cc9bc2e25379e69fcfb56f9e7d960c3cb326fb55 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Mon, 22 Dec 2025 11:19:52 +0900 Subject: [PATCH 21/42] Align V2 maximum depth with V1 (100) and update tests --- .../utility/WebCmdlet/ConvertToJsonCommandV2.cs | 8 ++++---- .../ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index b0069e227d7..cf1cf4cca3d 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -51,11 +51,11 @@ public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable /// /// Gets or sets the Depth property. - /// Default is 2. Maximum allowed is 1000. + /// Default is 2. Maximum allowed is 100. /// Use 0 to serialize only top-level properties. /// [Parameter] - [ValidateRange(0, 1000)] + [ValidateRange(0, 100)] public int Depth { get; set; } = 2; /// @@ -189,8 +189,8 @@ internal static class SystemTextJsonSerializer var options = new JsonSerializerOptions() { WriteIndented = !compressOutput, - // Use maximum allowed depth to avoid System.Text.Json exceptions - // Actual depth limiting is handled by JsonConverterPSObject + // Set high value to avoid System.Text.Json exceptions + // User-specified depth is enforced by JsonConverterPSObject (max 100 via ValidateRange) MaxDepth = 1000, DefaultIgnoreCondition = JsonIgnoreCondition.Never, Encoder = GetEncoder(stringEscapeHandling), diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index 5fc352ab85b..facfd5fd8a1 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -2,7 +2,7 @@ # Licensed under the MIT License. BeforeDiscovery { - $script:isV2Enabled = $EnabledExperimentalFeatures.Contains('PSJsonSerializerV2') + $isV2Enabled = $EnabledExperimentalFeatures.Contains('PSJsonSerializerV2') } Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { @@ -130,14 +130,14 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } } - Context "V1 only - Legacy limits" { - It "Depth over 100 should throw when V2 is disabled" -Skip:$script:isV2Enabled { + Context "V1/V2 compatible - Depth limits" { + It "Depth over 100 should throw" { { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw } } Context "V2 only - Depth exceeded warning" { - It "Should output warning when depth is exceeded" -Skip:(-not $script:isV2Enabled) { + It "Should output warning when depth is exceeded" -Skip:(-not $isV2Enabled) { $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue $json | Should -Not -BeNullOrEmpty @@ -146,14 +146,14 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } Context "V2 only - Non-string dictionary keys" { - It "Should serialize dictionary with integer keys" -Skip:(-not $script:isV2Enabled) { + It "Should serialize dictionary with integer keys" -Skip:(-not $isV2Enabled) { $dict = @{ 1 = "one"; 2 = "two" } $json = $dict | ConvertTo-Json -Compress $json | Should -Match '"1":\s*"one"' $json | Should -Match '"2":\s*"two"' } - It "Should serialize Exception.Data with non-string keys" -Skip:(-not $script:isV2Enabled) { + It "Should serialize Exception.Data with non-string keys" -Skip:(-not $isV2Enabled) { $ex = [System.Exception]::new("test") $ex.Data.Add(1, "value1") $ex.Data.Add("key", "value2") @@ -162,7 +162,7 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } Context "V2 only - HiddenAttribute" { - It "Should not serialize hidden properties in PowerShell class" -Skip:(-not $script:isV2Enabled) { + It "Should not serialize hidden properties in PowerShell class" -Skip:(-not $isV2Enabled) { class TestHiddenClass { [string] $Visible hidden [string] $Hidden @@ -177,7 +177,7 @@ Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { } Context "V2 only - Guid serialization" { - It "Should serialize Guid as string consistently" -Skip:(-not $script:isV2Enabled) { + It "Should serialize Guid as string consistently" -Skip:(-not $isV2Enabled) { $guid = [guid]"12345678-1234-1234-1234-123456789abc" # Both methods should produce the same string output (unlike V1 which is inconsistent) $jsonPipeline = $guid | ConvertTo-Json -Compress From c57b9e5972e9d724639f102ac1f7b1e4974ef267 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Mon, 22 Dec 2025 16:01:13 +0900 Subject: [PATCH 22/42] Fix CodeFactor style warnings in ConvertToJsonCommandV2 --- .../utility/WebCmdlet/ConvertToJsonCommandV2.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index cf1cf4cca3d..886b96d39e7 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -86,7 +86,7 @@ public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable public SwitchParameter AsArray { get; set; } /// - /// Specifies how strings are escaped when writing JSON text. + /// Gets or sets how strings are escaped when writing JSON text. /// If the EscapeHandling property is set to EscapeHtml, the result JSON string will /// be returned with HTML (<, >, &, ', ") and control characters (e.g. newline) are escaped. /// @@ -186,7 +186,7 @@ internal static class SystemTextJsonSerializer try { - var options = new JsonSerializerOptions() + var options = new JsonSerializerOptions() { WriteIndented = !compressOutput, // Set high value to avoid System.Text.Json exceptions @@ -226,7 +226,7 @@ internal static class SystemTextJsonSerializer Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => prop.Value.ToString() + _ => prop.Value.ToString(), }; System.Text.Json.JsonSerializer.Serialize(writer, value, options); } @@ -340,6 +340,7 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp WriteDepthExceeded(writer, pso, obj); return; } + // For dictionaries, collections, and custom objects if (obj is IDictionary dict) { @@ -385,7 +386,7 @@ private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => token.ToString() + _ => token.ToString(), }; System.Text.Json.JsonSerializer.Serialize(writer, value, options); } @@ -402,6 +403,7 @@ private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, else { var psoItem = PSObject.AsPSObject(item); + // Recursive call - Write will handle depth tracking Write(writer, psoItem, options); } @@ -614,5 +616,4 @@ public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializ writer.WriteRawValue(value.ToString(CultureInfo.InvariantCulture)); } } - } From b8403d3fb5c57e7844fb05029d0848db96bdf2b6 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Mon, 22 Dec 2025 16:05:34 +0900 Subject: [PATCH 23/42] Add missing blank lines for CodeFactor style compliance --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 886b96d39e7..6a6945e1a60 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -189,6 +189,7 @@ internal static class SystemTextJsonSerializer var options = new JsonSerializerOptions() { WriteIndented = !compressOutput, + // Set high value to avoid System.Text.Json exceptions // User-specified depth is enforced by JsonConverterPSObject (max 100 via ValidateRange) MaxDepth = 1000, @@ -230,8 +231,10 @@ internal static class SystemTextJsonSerializer }; System.Text.Json.JsonSerializer.Serialize(writer, value, options); } + writer.WriteEndObject(); } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); } From b37e6aeb7cb2d56b8b77b7f312c74c74df420c59 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Mon, 22 Dec 2025 19:13:09 +0900 Subject: [PATCH 24/42] Address code review feedback from iSazonov --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 98 +++----- ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 221 ++++-------------- .../ConvertTo-Json.Tests.ps1 | 80 +++++++ 3 files changed, 150 insertions(+), 249 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 6a6945e1a60..748161d3570 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -38,7 +38,7 @@ namespace Microsoft.PowerShell.Commands [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Show)] [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)] [OutputType(typeof(string))] - public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable + public class ConvertToJsonCommandV2 : PSCmdlet { /// /// Gets or sets the InputObject property. @@ -47,8 +47,6 @@ public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable [AllowNull] public object? InputObject { get; set; } - private readonly CancellationTokenSource _cancellationSource = new(); - /// /// Gets or sets the Depth property. /// Default is 2. Maximum allowed is 100. @@ -93,29 +91,6 @@ public class ConvertToJsonCommandV2 : PSCmdlet, IDisposable [Parameter] public NewtonsoftStringEscapeHandling EscapeHandling { get; set; } = NewtonsoftStringEscapeHandling.Default; - /// - /// IDisposable implementation, dispose of any disposable resources created by the cmdlet. - /// - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - /// - /// Implementation of IDisposable for both manual Dispose() and finalizer-called disposal of resources. - /// - /// - /// Specified as true when Dispose() was called, false if this is called from the finalizer. - /// - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _cancellationSource.Dispose(); - } - } - private readonly List _inputObjects = new(); /// @@ -142,7 +117,7 @@ protected override void EndProcessing() Compress.IsPresent, EscapeHandling, this, - _cancellationSource.Token); + PipelineStopToken); // null is returned only if the pipeline is stopping (e.g. ctrl+c is signaled). // in that case, we shouldn't write the null to the output pipe. @@ -152,14 +127,6 @@ protected override void EndProcessing() } } } - - /// - /// Process the Ctrl+C signal. - /// - protected override void StopProcessing() - { - _cancellationSource.Cancel(); - } } /// @@ -214,27 +181,17 @@ internal static class SystemTextJsonSerializer { // Serialize JObject directly using our custom logic using var stream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = !compressOutput, Encoder = GetEncoder(stringEscapeHandling) })) + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = !compressOutput, Encoder = GetEncoder(stringEscapeHandling) }); + writer.WriteStartObject(); + foreach (var prop in jObj.Properties()) { - writer.WriteStartObject(); - foreach (var prop in jObj.Properties()) - { - writer.WritePropertyName(prop.Name); - var value = prop.Value.Type switch - { - Newtonsoft.Json.Linq.JTokenType.String => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Integer => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Float => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Boolean => prop.Value.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => prop.Value.ToString(), - }; - System.Text.Json.JsonSerializer.Serialize(writer, value, options); - } - - writer.WriteEndObject(); + writer.WritePropertyName(prop.Name); + WriteJTokenValue(writer, prop.Value, options); } + writer.WriteEndObject(); + writer.Flush(); + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); } @@ -258,6 +215,23 @@ private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escap _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; } + + /// + /// Writes a Newtonsoft JToken value to the Utf8JsonWriter. + /// + internal static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options) + { + var value = token.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => token.ToString(), + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } } /// @@ -322,7 +296,7 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp foreach (var prop in jObject.Properties()) { writer.WritePropertyName(prop.Name); - WriteJTokenValue(writer, prop.Value, options); + SystemTextJsonSerializer.WriteJTokenValue(writer, prop.Value, options); } writer.WriteEndObject(); @@ -349,7 +323,7 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp { SerializeDictionary(writer, pso, dict, options); } - else if (obj is IEnumerable enumerable and not string) + else if (obj is IEnumerable enumerable) { SerializeEnumerable(writer, enumerable, options); } @@ -380,20 +354,6 @@ private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) writer.WriteStringValue(stringValue); } - private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options) - { - var value = token.Type switch - { - Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => token.ToString(), - }; - System.Text.Json.JsonSerializer.Serialize(writer, value, options); - } - private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) { writer.WriteStartArray(); diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index facfd5fd8a1..d0456d6708f 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -5,185 +5,46 @@ BeforeDiscovery { $isV2Enabled = $EnabledExperimentalFeatures.Contains('PSJsonSerializerV2') } -Describe 'ConvertTo-Json with PSJsonSerializerV2' -Tags "CI" { - Context "V1/V2 compatible - Special types" { - It "Should serialize Uri correctly" { - $uri = [uri]"https://example.com/path" - $json = $uri | ConvertTo-Json -Compress - $json | Should -BeExactly '"https://example.com/path"' - } - - It "Should serialize BigInteger correctly" { - $big = [System.Numerics.BigInteger]::Parse("123456789012345678901234567890") - $json = ConvertTo-Json -InputObject $big -Compress - $json | Should -BeExactly '123456789012345678901234567890' - } - - It "Should serialize enums as numbers by default" { - $json = [System.DayOfWeek]::Monday | ConvertTo-Json - $json | Should -BeExactly '1' - } - - It "Should serialize enums as strings with -EnumsAsStrings" { - $json = [System.DayOfWeek]::Monday | ConvertTo-Json -EnumsAsStrings - $json | Should -BeExactly '"Monday"' - } - } - - Context "V1/V2 compatible - Null handling" { - It "Should serialize null correctly" { - $null | ConvertTo-Json | Should -BeExactly 'null' - } - - It "Should serialize DBNull as null" { - [System.DBNull]::Value | ConvertTo-Json | Should -BeExactly 'null' - } - - It "Should serialize NullString as null" { - [NullString]::Value | ConvertTo-Json | Should -BeExactly 'null' - } - - It "Should handle ETS properties on DBNull" { - try { - $p = Add-Member -InputObject ([System.DBNull]::Value) -MemberType NoteProperty -Name testprop -Value 'testvalue' -PassThru - $json = $p | ConvertTo-Json -Compress - $json | Should -Match '"value":null' - $json | Should -Match '"testprop":"testvalue"' - } - finally { - $p.psobject.Properties.Remove('testprop') - } - } - } - - Context "V1/V2 compatible - Collections" { - It "Should serialize arrays correctly" { - $arr = @(1, 2, 3) - $json = $arr | ConvertTo-Json -Compress - $json | Should -BeExactly '[1,2,3]' - } - - It "Should serialize hashtable correctly" { - $hash = [ordered]@{ a = 1; b = 2 } - $json = $hash | ConvertTo-Json -Compress - $json | Should -BeExactly '{"a":1,"b":2}' - } - - It "Should serialize nested objects correctly" { - $obj = [pscustomobject]@{ - name = "test" - child = [pscustomobject]@{ - value = 42 - } - } - $json = $obj | ConvertTo-Json -Compress - $json | Should -BeExactly '{"name":"test","child":{"value":42}}' - } - } - - Context "V1/V2 compatible - EscapeHandling" { - It "Should not escape by default" { - $json = @{ text = "<>&" } | ConvertTo-Json -Compress - $json | Should -BeExactly '{"text":"<>&"}' - } - - It "Should escape HTML with -EscapeHandling EscapeHtml" { - $json = @{ text = "<>&" } | ConvertTo-Json -Compress -EscapeHandling EscapeHtml - $json | Should -Match '\\u003C' - $json | Should -Match '\\u003E' - $json | Should -Match '\\u0026' - } - - It "Should escape non-ASCII with -EscapeHandling EscapeNonAscii" { - $json = @{ text = "日本語" } | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii - $json | Should -Match '\\u' - } - } - - Context "V1/V2 compatible - Backward compatibility" { - It "Should still support Newtonsoft JObject" { - $jobj = New-Object Newtonsoft.Json.Linq.JObject - $jobj.Add("key", [Newtonsoft.Json.Linq.JToken]::FromObject("value")) - $json = @{ data = $jobj } | ConvertTo-Json -Compress -Depth 2 - $json | Should -Match '"key":\s*"value"' - } - - It "Depth parameter should work" { - $obj = @{ a = @{ b = 1 } } - $json = $obj | ConvertTo-Json -Depth 2 -Compress - $json | Should -BeExactly '{"a":{"b":1}}' - } - - It "AsArray parameter should work" { - $json = @{a=1} | ConvertTo-Json -AsArray -Compress - $json | Should -BeExactly '[{"a":1}]' - } - - It "Multiple objects from pipeline should be serialized as array" { - $json = 1, 2, 3 | ConvertTo-Json -Compress - $json | Should -BeExactly '[1,2,3]' - } - - It "Multiple objects from pipeline with AsArray should work" { - $json = @{a=1}, @{b=2} | ConvertTo-Json -AsArray -Compress - $json | Should -Match '^\[.*\]$' - } - } - - Context "V1/V2 compatible - Depth limits" { - It "Depth over 100 should throw" { - { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw - } - } - - Context "V2 only - Depth exceeded warning" { - It "Should output warning when depth is exceeded" -Skip:(-not $isV2Enabled) { - $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } - $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Not -BeNullOrEmpty - $warn | Should -Not -BeNullOrEmpty - } - } - - Context "V2 only - Non-string dictionary keys" { - It "Should serialize dictionary with integer keys" -Skip:(-not $isV2Enabled) { - $dict = @{ 1 = "one"; 2 = "two" } - $json = $dict | ConvertTo-Json -Compress - $json | Should -Match '"1":\s*"one"' - $json | Should -Match '"2":\s*"two"' - } - - It "Should serialize Exception.Data with non-string keys" -Skip:(-not $isV2Enabled) { - $ex = [System.Exception]::new("test") - $ex.Data.Add(1, "value1") - $ex.Data.Add("key", "value2") - { $ex | ConvertTo-Json -Depth 1 } | Should -Not -Throw - } - } - - Context "V2 only - HiddenAttribute" { - It "Should not serialize hidden properties in PowerShell class" -Skip:(-not $isV2Enabled) { - class TestHiddenClass { - [string] $Visible - hidden [string] $Hidden - } - $obj = [TestHiddenClass]::new() - $obj.Visible = "yes" - $obj.Hidden = "no" - $json = $obj | ConvertTo-Json -Compress - $json | Should -Match 'Visible' - $json | Should -Not -Match 'Hidden' - } - } - - Context "V2 only - Guid serialization" { - It "Should serialize Guid as string consistently" -Skip:(-not $isV2Enabled) { - $guid = [guid]"12345678-1234-1234-1234-123456789abc" - # Both methods should produce the same string output (unlike V1 which is inconsistent) - $jsonPipeline = $guid | ConvertTo-Json -Compress - $jsonInputObject = ConvertTo-Json -InputObject $guid -Compress - $jsonPipeline | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' - $jsonInputObject | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' - } +Describe 'ConvertTo-Json PSJsonSerializerV2 specific behavior' -Tags "CI" -Skip:(-not $isV2Enabled) { + It 'Should output warning when depth is exceeded' { + $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } + $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + } + + It 'Should serialize dictionary with integer keys' { + $dict = @{ 1 = "one"; 2 = "two" } + $json = $dict | ConvertTo-Json -Compress + $json | Should -Match '"1":\s*"one"' + $json | Should -Match '"2":\s*"two"' + } + + It 'Should serialize Exception.Data with non-string keys' { + $ex = [System.Exception]::new("test") + $ex.Data.Add(1, "value1") + $ex.Data.Add("key", "value2") + { $ex | ConvertTo-Json -Depth 1 } | Should -Not -Throw + } + + It 'Should not serialize hidden properties in PowerShell class' { + class TestHiddenClass { + [string] $Visible + hidden [string] $Hidden + } + $obj = [TestHiddenClass]::new() + $obj.Visible = "yes" + $obj.Hidden = "no" + $json = $obj | ConvertTo-Json -Compress + $json | Should -Match 'Visible' + $json | Should -Not -Match 'Hidden' + } + + It 'Should serialize Guid as string consistently' { + $guid = [guid]"12345678-1234-1234-1234-123456789abc" + $jsonPipeline = $guid | ConvertTo-Json -Compress + $jsonInputObject = ConvertTo-Json -InputObject $guid -Compress + $jsonPipeline | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' + $jsonInputObject | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' } } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 index 1f2abe05c68..b71195f706a 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 @@ -156,4 +156,84 @@ Describe 'ConvertTo-Json' -tags "CI" { $actual = ConvertTo-Json -Compress -InputObject $obj $actual | Should -Be '{"Positive":18446744073709551615,"Negative":-18446744073709551615}' } + + It 'Should serialize Uri correctly' { + $uri = [uri]"https://example.com/path" + $json = $uri | ConvertTo-Json -Compress + $json | Should -BeExactly '"https://example.com/path"' + } + + It 'Should serialize enums as numbers by default' { + $json = [System.DayOfWeek]::Monday | ConvertTo-Json + $json | Should -BeExactly '1' + } + + It 'Should serialize enums as strings with -EnumsAsStrings' { + $json = [System.DayOfWeek]::Monday | ConvertTo-Json -EnumsAsStrings + $json | Should -BeExactly '"Monday"' + } + + It 'Should serialize null correctly' { + $null | ConvertTo-Json | Should -BeExactly 'null' + } + + It 'Should serialize arrays correctly' { + $arr = @(1, 2, 3) + $json = $arr | ConvertTo-Json -Compress + $json | Should -BeExactly '[1,2,3]' + } + + It 'Should serialize hashtable correctly' { + $hash = [ordered]@{ a = 1; b = 2 } + $json = $hash | ConvertTo-Json -Compress + $json | Should -BeExactly '{"a":1,"b":2}' + } + + It 'Should serialize nested objects correctly' { + $obj = [pscustomobject]@{ + name = "test" + child = [pscustomobject]@{ + value = 42 + } + } + $json = $obj | ConvertTo-Json -Compress + $json | Should -BeExactly '{"name":"test","child":{"value":42}}' + } + + It 'Should not escape by default' { + $json = @{ text = "<>&" } | ConvertTo-Json -Compress + $json | Should -BeExactly '{"text":"<>&"}' + } + + It 'Should escape HTML with -EscapeHandling EscapeHtml' { + $json = @{ text = "<>&" } | ConvertTo-Json -Compress -EscapeHandling EscapeHtml + $json | Should -Match '\\u003C' + $json | Should -Match '\\u003E' + $json | Should -Match '\\u0026' + } + + It 'Should escape non-ASCII with -EscapeHandling EscapeNonAscii' { + $json = @{ text = "日本語" } | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii + $json | Should -Match '\\u' + } + + It 'Depth parameter should work' { + $obj = @{ a = @{ b = 1 } } + $json = $obj | ConvertTo-Json -Depth 2 -Compress + $json | Should -BeExactly '{"a":{"b":1}}' + } + + It 'AsArray parameter should work' { + $json = @{a=1} | ConvertTo-Json -AsArray -Compress + $json | Should -BeExactly '[{"a":1}]' + } + + It 'Multiple objects from pipeline should be serialized as array' { + $json = 1, 2, 3 | ConvertTo-Json -Compress + $json | Should -BeExactly '[1,2,3]' + } + + It 'Depth over 100 should throw' { + { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw + } } From 9e8ae20ee410a318c5833fe7d7219e356834dc79 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Tue, 23 Dec 2025 07:14:20 +0900 Subject: [PATCH 25/42] Remove unused using alias and simplify XML documentation --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 748161d3570..3e16882d399 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -23,17 +23,15 @@ using Newtonsoft.Json; using NewtonsoftStringEscapeHandling = Newtonsoft.Json.StringEscapeHandling; -using StjJsonIgnoreAttribute = System.Text.Json.Serialization.JsonIgnoreAttribute; namespace Microsoft.PowerShell.Commands { /// - /// The ConvertTo-Json command (V2 - System.Text.Json implementation). + /// The ConvertTo-Json command. /// This command converts an object to a Json string representation. /// /// /// This class is shown when PSJsonSerializerV2 experimental feature is enabled. - /// V2 uses System.Text.Json with V1-compatible depth handling. /// [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Show)] [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)] From db4cd5e9a31d65901f30d1d60861a91f22b23ca3 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Tue, 23 Dec 2025 10:14:22 +0900 Subject: [PATCH 26/42] Refactor JObject handling into dedicated JsonConverterJObject --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 86 +++++++++---------- 1 file changed, 40 insertions(+), 46 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 3e16882d399..3ab3d2a9ea6 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -173,25 +173,7 @@ internal static class SystemTextJsonSerializer options.Converters.Add(new JsonConverterNullString()); options.Converters.Add(new JsonConverterDBNull()); options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth)); - - // Handle JObject specially to avoid IEnumerable serialization - if (objectToProcess is Newtonsoft.Json.Linq.JObject jObj) - { - // Serialize JObject directly using our custom logic - using var stream = new System.IO.MemoryStream(); - using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = !compressOutput, Encoder = GetEncoder(stringEscapeHandling) }); - writer.WriteStartObject(); - foreach (var prop in jObj.Properties()) - { - writer.WritePropertyName(prop.Name); - WriteJTokenValue(writer, prop.Value, options); - } - - writer.WriteEndObject(); - writer.Flush(); - - return System.Text.Encoding.UTF8.GetString(stream.ToArray()); - } + options.Converters.Add(new JsonConverterJObject()); // Wrap in PSObject to ensure ETS properties are preserved var pso = PSObject.AsPSObject(objectToProcess); @@ -213,23 +195,6 @@ private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escap _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; } - - /// - /// Writes a Newtonsoft JToken value to the Utf8JsonWriter. - /// - internal static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options) - { - var value = token.Type switch - { - Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), - Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, - _ => token.ToString(), - }; - System.Text.Json.JsonSerializer.Serialize(writer, value, options); - } } /// @@ -287,18 +252,10 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp return; } - // Handle Newtonsoft.Json.Linq.JObject by converting properties manually + // Handle Newtonsoft.Json.Linq.JObject by delegating to JsonConverterJObject if (obj is Newtonsoft.Json.Linq.JObject jObject) { - writer.WriteStartObject(); - foreach (var prop in jObject.Properties()) - { - writer.WritePropertyName(prop.Name); - SystemTextJsonSerializer.WriteJTokenValue(writer, prop.Value, options); - } - - writer.WriteEndObject(); - + System.Text.Json.JsonSerializer.Serialize(writer, jObject, options); return; } @@ -577,4 +534,41 @@ public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializ writer.WriteRawValue(value.ToString(CultureInfo.InvariantCulture)); } } + + /// + /// Custom JsonConverter for Newtonsoft.Json.Linq.JObject to isolate Newtonsoft-related code. + /// + internal sealed class JsonConverterJObject : System.Text.Json.Serialization.JsonConverter + { + public override Newtonsoft.Json.Linq.JObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JObject jObject, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (var prop in jObject.Properties()) + { + writer.WritePropertyName(prop.Name); + WriteJTokenValue(writer, prop.Value, options); + } + + writer.WriteEndObject(); + } + + private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options) + { + var value = token.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(), + Newtonsoft.Json.Linq.JTokenType.Null => (object?)null, + _ => token.ToString(), + }; + System.Text.Json.JsonSerializer.Serialize(writer, value, options); + } + } } From faba85d5c829436648041063bdd9ec381346b1ff Mon Sep 17 00:00:00 2001 From: yotsuda Date: Tue, 23 Dec 2025 11:54:18 +0900 Subject: [PATCH 27/42] Add JsonStringEscapeHandling enum with backward compatibility --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 3ab3d2a9ea6..c6f37dee705 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -22,10 +22,45 @@ using Newtonsoft.Json; -using NewtonsoftStringEscapeHandling = Newtonsoft.Json.StringEscapeHandling; - namespace Microsoft.PowerShell.Commands { + /// + /// Specifies how strings are escaped when writing JSON text. + /// + public enum JsonStringEscapeHandling + { + /// + /// Only control characters (e.g. newline) are escaped. + /// + Default = 0, + + /// + /// All non-ASCII and control characters are escaped. + /// + EscapeNonAscii = 1, + + /// + /// HTML (<, >, &, ', ") and control characters are escaped. + /// + EscapeHtml = 2, + } + + /// + /// Transforms Newtonsoft.Json.StringEscapeHandling to JsonStringEscapeHandling for backward compatibility. + /// + internal sealed class StringEscapeHandlingTransformationAttribute : ArgumentTransformationAttribute + { + public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) + { + if (inputData is Newtonsoft.Json.StringEscapeHandling newtonsoftValue) + { + return (JsonStringEscapeHandling)(int)newtonsoftValue; + } + + return inputData; + } + } + /// /// The ConvertTo-Json command. /// This command converts an object to a Json string representation. @@ -87,7 +122,8 @@ public class ConvertToJsonCommandV2 : PSCmdlet /// be returned with HTML (<, >, &, ', ") and control characters (e.g. newline) are escaped. /// [Parameter] - public NewtonsoftStringEscapeHandling EscapeHandling { get; set; } = NewtonsoftStringEscapeHandling.Default; + [StringEscapeHandlingTransformation] + public JsonStringEscapeHandling EscapeHandling { get; set; } = JsonStringEscapeHandling.Default; private readonly List _inputObjects = new(); @@ -140,7 +176,7 @@ internal static class SystemTextJsonSerializer int maxDepth, bool enumsAsStrings, bool compressOutput, - NewtonsoftStringEscapeHandling stringEscapeHandling, + JsonStringEscapeHandling stringEscapeHandling, PSCmdlet? cmdlet, CancellationToken cancellationToken) { @@ -185,13 +221,13 @@ internal static class SystemTextJsonSerializer } } - private static JavaScriptEncoder GetEncoder(NewtonsoftStringEscapeHandling escapeHandling) + private static JavaScriptEncoder GetEncoder(JsonStringEscapeHandling escapeHandling) { return escapeHandling switch { - NewtonsoftStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - NewtonsoftStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, - NewtonsoftStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), + JsonStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + JsonStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, + JsonStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; } From 0e07b33c9076817eb166eedb980b4cbbbafdab67 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Tue, 23 Dec 2025 18:48:06 +0900 Subject: [PATCH 28/42] Address code review feedback: remove unused using, optimize allocation, add nullability, use expression body, reorder null check, annotate IsNull, honor PSSerializeJSONLongEnumAsNumber, avoid double enumeration, add JsonIgnore check --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 120 ++++++++++-------- .../engine/LanguagePrimitives.cs | 6 +- 2 files changed, 74 insertions(+), 52 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index c6f37dee705..fc003a6610a 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -20,8 +20,6 @@ using System.Text.Unicode; using System.Threading; -using Newtonsoft.Json; - namespace Microsoft.PowerShell.Commands { /// @@ -142,7 +140,7 @@ protected override void EndProcessing() { if (_inputObjects.Count > 0) { - object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (_inputObjects.ToArray() as object) : _inputObjects[0]; + object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (object)_inputObjects : _inputObjects[0]; string? output = SystemTextJsonSerializer.ConvertToJson( objectToProcess, @@ -204,7 +202,10 @@ internal static class SystemTextJsonSerializer } // Add custom converters for PowerShell-specific types - options.Converters.Add(new JsonConverterInt64Enum()); + if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSSerializeJSONLongEnumAsNumber)) + { + options.Converters.Add(new JsonConverterInt64Enum()); + } options.Converters.Add(new JsonConverterBigInteger()); options.Converters.Add(new JsonConverterNullString()); options.Converters.Add(new JsonConverterDBNull()); @@ -221,16 +222,14 @@ internal static class SystemTextJsonSerializer } } - private static JavaScriptEncoder GetEncoder(JsonStringEscapeHandling escapeHandling) - { - return escapeHandling switch + private static JavaScriptEncoder GetEncoder(JsonStringEscapeHandling escapeHandling) => + escapeHandling switch { JsonStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, JsonStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, JsonStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin), _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; - } } /// @@ -255,7 +254,7 @@ public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) throw new NotImplementedException(); } - public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerOptions options) { if (LanguagePrimitives.IsNull(pso)) { @@ -263,21 +262,37 @@ public override void Write(Utf8JsonWriter writer, PSObject pso, JsonSerializerOp return; } - var obj = pso.BaseObject; + var obj = pso!.BaseObject; int currentDepth = writer.CurrentDepth; // Handle special types - check for null-like objects (no depth increment needed) if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) { - // Check if PSObject has Extended/Adapted properties - bool hasETSProps = pso.Properties.Match("*", PSMemberTypes.NoteProperty | PSMemberTypes.AliasProperty).Count > 0; - if (hasETSProps) + // Single enumeration: write properties directly as we find them + var etsProperties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(PSMemberViewTypes.Extended)); + + bool wroteStart = false; + foreach (var prop in etsProperties) + { + if (!ShouldSkipProperty(prop)) + { + if (!wroteStart) + { + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + wroteStart = true; + } + + WriteProperty(writer, prop, options); + } + } + + if (wroteStart) { - writer.WriteStartObject(); - writer.WritePropertyName("value"); - writer.WriteNullValue(); - AppendPSProperties(writer, pso, options, excludeBaseProperties: true); writer.WriteEndObject(); } else @@ -339,9 +354,7 @@ private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) } // Convert to string when depth exceeded - string stringValue = pso.ImmediateBaseObjectIsEmpty - ? LanguagePrimitives.ConvertTo(pso) - : LanguagePrimitives.ConvertTo(obj); + string stringValue = LanguagePrimitives.ConvertTo(pso.ImmediateBaseObjectIsEmpty ? pso : obj); writer.WriteStringValue(stringValue); } @@ -440,43 +453,43 @@ private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSeriali foreach (var prop in properties) { - // Skip properties with JsonIgnore attribute or Hidden attribute if (ShouldSkipProperty(prop)) { continue; } - try - { - var value = prop.Value; - writer.WritePropertyName(prop.Name); + WriteProperty(writer, prop, options); + } + } - // If maxDepth is 0, convert non-primitive values to string - if (_maxDepth == 0 && value is not null && !IsPrimitiveTypeOrNull(value)) - { - writer.WriteStringValue(value.ToString()); - } - else - { - // Handle null values directly (including AutomationNull) - if (LanguagePrimitives.IsNull(value)) - { - writer.WriteNullValue(); - } - else - { - // Wrap value in PSObject to ensure custom converters are applied - var psoValue = PSObject.AsPSObject(value); - System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); - } - } + private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) + { + try + { + var value = prop.Value; + writer.WritePropertyName(prop.Name); + + // Handle null values directly (including AutomationNull) + if (LanguagePrimitives.IsNull(value)) + { + writer.WriteNullValue(); } - catch + // If maxDepth is 0, convert non-primitive values to string + else if (_maxDepth == 0 && !IsPrimitiveTypeOrNull(value)) { - // Skip properties that throw on access - continue; + writer.WriteStringValue(value!.ToString()); + } + else + { + // Wrap value in PSObject to ensure custom converters are applied + var psoValue = PSObject.AsPSObject(value); + System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); } } + catch + { + // Skip properties that throw on access - write nothing for this property + } } private static bool ShouldSkipProperty(PSPropertyInfo prop) @@ -487,9 +500,16 @@ private static bool ShouldSkipProperty(PSPropertyInfo prop) return true; } - // Note: JsonIgnoreAttribute check would require reflection on the underlying member - // which may not be available for all PSPropertyInfo types. For now, we rely on - // IsHidden to filter properties that should not be serialized. + // Check for JsonIgnoreAttribute on the underlying member + if (prop is PSProperty psProperty) + { + if (psProperty.adapterData is MemberInfo memberInfo && + memberInfo.GetCustomAttribute() is not null) + { + return true; + } + } + return false; } } diff --git a/src/System.Management.Automation/engine/LanguagePrimitives.cs b/src/System.Management.Automation/engine/LanguagePrimitives.cs index 13d8f66e5e9..4ac2e07a7e9 100644 --- a/src/System.Management.Automation/engine/LanguagePrimitives.cs +++ b/src/System.Management.Automation/engine/LanguagePrimitives.cs @@ -1056,10 +1056,12 @@ internal static bool IsTrue(IList objectArray) /// /// The object to test. /// True if the object is null. - internal static bool IsNull(object obj) +#nullable enable + internal static bool IsNull([NotNullWhen(false)] object? obj) { - return (obj == null || obj == AutomationNull.Value); + return obj is null || obj == AutomationNull.Value; } +#nullable restore /// /// Auxiliary for the cases where we want a new PSObject or null. From 6cb364b060d7626313a07c64cc4d8c1364f155b6 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 24 Dec 2025 00:21:08 +0900 Subject: [PATCH 29/42] Add blank line before single-line comment to satisfy CodeFactor --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index fc003a6610a..85ef2afde13 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -474,6 +474,7 @@ private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSeria { writer.WriteNullValue(); } + // If maxDepth is 0, convert non-primitive values to string else if (_maxDepth == 0 && !IsPrimitiveTypeOrNull(value)) { From 9ec49572442d72d71ebf75c78451eb7445ae4cc1 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 24 Dec 2025 00:22:56 +0900 Subject: [PATCH 30/42] Add blank line after closing brace to satisfy CodeFactor --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 85ef2afde13..49d947d8c5d 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -206,6 +206,7 @@ internal static class SystemTextJsonSerializer { options.Converters.Add(new JsonConverterInt64Enum()); } + options.Converters.Add(new JsonConverterBigInteger()); options.Converters.Add(new JsonConverterNullString()); options.Converters.Add(new JsonConverterDBNull()); From b201e6ad0232db258cc9f6c1cc0659994aea4cd8 Mon Sep 17 00:00:00 2001 From: Yoshifumi Date: Wed, 24 Dec 2025 18:49:31 +0900 Subject: [PATCH 31/42] Update src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs Co-authored-by: Ilya --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 49d947d8c5d..fce0c37d60e 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -61,7 +61,7 @@ public override object Transform(EngineIntrinsics engineIntrinsics, object input /// /// The ConvertTo-Json command. - /// This command converts an object to a Json string representation. + /// This command converts an object to a JSON string representation. /// /// /// This class is shown when PSJsonSerializerV2 experimental feature is enabled. From 0752a8b79a4ae31547231f359564ac771bceca35 Mon Sep 17 00:00:00 2001 From: Yoshifumi Date: Wed, 24 Dec 2025 18:50:40 +0900 Subject: [PATCH 32/42] Update src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs Co-authored-by: Ilya --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index fce0c37d60e..7af6948556f 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -140,7 +140,7 @@ protected override void EndProcessing() { if (_inputObjects.Count > 0) { - object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? (object)_inputObjects : _inputObjects[0]; + object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? _inputObjects : _inputObjects[0]; string? output = SystemTextJsonSerializer.ConvertToJson( objectToProcess, From 5d617f1fce95a0b025afe9a4e2fa59efba3a97dc Mon Sep 17 00:00:00 2001 From: Yoshifumi Date: Wed, 24 Dec 2025 18:51:46 +0900 Subject: [PATCH 33/42] Update src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs Co-authored-by: Ilya --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 7af6948556f..9396af466dc 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -263,7 +263,7 @@ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerO return; } - var obj = pso!.BaseObject; + object? obj = pso.BaseObject; int currentDepth = writer.CurrentDepth; From fdbe94ae6be40fff297b6c3654519b2eaa6ad74e Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 24 Dec 2025 20:49:01 +0900 Subject: [PATCH 34/42] Match V1 serialization for nested raw objects --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 238 ++++++++++++++++-- .../ConvertTo-Json.Tests.ps1 | 226 +++++++++++------ test/xUnit/csharp/test_ConvertToJson.cs | 23 ++ 3 files changed, 382 insertions(+), 105 deletions(-) create mode 100644 test/xUnit/csharp/test_ConvertToJson.cs diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 9396af466dc..c95cfe356cb 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -25,6 +25,10 @@ namespace Microsoft.PowerShell.Commands /// /// Specifies how strings are escaped when writing JSON text. /// + /// + /// The numeric values must match Newtonsoft.Json.StringEscapeHandling for backward compatibility. + /// Do not change these values. + /// public enum JsonStringEscapeHandling { /// @@ -49,14 +53,7 @@ public enum JsonStringEscapeHandling internal sealed class StringEscapeHandlingTransformationAttribute : ArgumentTransformationAttribute { public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) - { - if (inputData is Newtonsoft.Json.StringEscapeHandling newtonsoftValue) - { - return (JsonStringEscapeHandling)(int)newtonsoftValue; - } - - return inputData; - } + => inputData is Newtonsoft.Json.StringEscapeHandling newtonsoftValue ? (JsonStringEscapeHandling)(int)newtonsoftValue : inputData; } /// @@ -213,7 +210,6 @@ internal static class SystemTextJsonSerializer options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth)); options.Converters.Add(new JsonConverterJObject()); - // Wrap in PSObject to ensure ETS properties are preserved var pso = PSObject.AsPSObject(objectToProcess); return System.Text.Json.JsonSerializer.Serialize(pso, typeof(PSObject), options); } @@ -241,7 +237,6 @@ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.Jso private readonly PSCmdlet? _cmdlet; private readonly int _maxDepth; - // Warning tracking (instance variable, reset per serialization) private bool _warningWritten; public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) @@ -314,7 +309,7 @@ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerO // If PSObject wraps a primitive type, serialize the base object directly (no depth increment) if (IsPrimitiveType(obj)) { - System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); + SerializePrimitive(writer, obj, options); return; } @@ -399,6 +394,53 @@ private static bool IsPrimitiveTypeOrNull(object? obj) return obj is null || IsPrimitiveType(obj); } + private static void SerializePrimitive(Utf8JsonWriter writer, object obj, JsonSerializerOptions options) + { + // Handle special floating-point values (Infinity, NaN) as strings for V1 compatibility + if (obj is double d) + { + if (double.IsPositiveInfinity(d)) + { + writer.WriteStringValue("Infinity"); + return; + } + + if (double.IsNegativeInfinity(d)) + { + writer.WriteStringValue("-Infinity"); + return; + } + + if (double.IsNaN(d)) + { + writer.WriteStringValue("NaN"); + return; + } + } + else if (obj is float f) + { + if (float.IsPositiveInfinity(f)) + { + writer.WriteStringValue("Infinity"); + return; + } + + if (float.IsNegativeInfinity(f)) + { + writer.WriteStringValue("-Infinity"); + return; + } + + if (float.IsNaN(f)) + { + writer.WriteStringValue("NaN"); + return; + } + } + + System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); + } + private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options) { writer.WriteStartObject(); @@ -412,42 +454,120 @@ private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionar } // Add PSObject extended properties - AppendPSProperties(writer, pso, options, excludeBaseProperties: true); + AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended); writer.WriteEndObject(); } private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) { - if (value is null) + if (value is null or DBNull or System.Management.Automation.Language.NullString) { writer.WriteNullValue(); } else if (IsPrimitiveType(value)) { - System.Text.Json.JsonSerializer.Serialize(writer, value, value.GetType(), options); + SerializePrimitive(writer, value, options); + } + else if (value is Newtonsoft.Json.Linq.JObject jObject) + { + System.Text.Json.JsonSerializer.Serialize(writer, jObject, options); } else { - // Non-primitive: wrap in PSObject and call Write for depth tracking - var psoValue = PSObject.AsPSObject(value); - Write(writer, psoValue, options); + // Check if value was originally a PSObject (preserves Extended/Adapted properties) + // or a raw .NET object (serialize with Base properties only for V1 compatibility) + if (value is PSObject psoValue) + { + Write(writer, psoValue, options); + } + else + { + var pso = PSObject.AsPSObject(value); + SerializeRawValue(writer, pso, options); + } } } private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) { writer.WriteStartObject(); - AppendPSProperties(writer, pso, options, excludeBaseProperties: false); + AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted); writer.WriteEndObject(); } - private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, bool excludeBaseProperties) + /// + /// Serializes a raw .NET object with Base properties only (V1 compatible behavior). + /// + private void SerializeRawValue(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + object obj = pso.BaseObject; + + // Primitive types: serialize directly + if (IsPrimitiveType(obj)) + { + SerializePrimitive(writer, obj, options); + return; + } + + // Check depth limit + int currentDepth = writer.CurrentDepth; + if (currentDepth > _maxDepth) + { + WriteDepthExceeded(writer, pso, obj); + return; + } + + // Dictionary: serialize with standard dictionary handling + if (obj is IDictionary dict) + { + SerializeDictionary(writer, pso, dict, options); + return; + } + + // Enumerable: serialize with raw item handling + if (obj is IEnumerable enumerable) + { + SerializeEnumerableRaw(writer, enumerable, options); + return; + } + + // Object: serialize with Base properties only (MemberType == Property) + writer.WriteStartObject(); + AppendBaseProperties(writer, pso, options); + writer.WriteEndObject(); + } + + /// + /// Serializes an enumerable with raw .NET object handling (Base properties only). + /// + private void SerializeEnumerableRaw(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) { - var memberTypes = excludeBaseProperties - ? PSMemberViewTypes.Extended - : (PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted); + writer.WriteStartArray(); + foreach (var item in enumerable) + { + if (item is null) + { + writer.WriteNullValue(); + } + else if (item is PSObject psoItem) + { + // Existing PSObject: use standard serialization + Write(writer, psoItem, options); + } + else + { + // Raw object: serialize with Base properties only + var pso = PSObject.AsPSObject(item); + SerializeRawValue(writer, pso, options); + } + } + + writer.WriteEndArray(); + } + private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, PSMemberViewTypes memberTypes) + { var properties = new PSMemberInfoIntegratingCollection( pso, PSObject.GetPropertyCollection(memberTypes)); @@ -463,6 +583,64 @@ private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSeriali } } + /// + /// Appends only base .NET properties (MemberType == Property) for V1 compatibility. + /// + private void AppendBaseProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + // Use Adapted view which includes .NET properties via DotNetAdapter + var properties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(PSMemberViewTypes.Adapted)); + + foreach (var prop in properties) + { + // Filter to only Property type (excludes CodeProperty, ScriptProperty, etc.) + if (prop.MemberType != PSMemberTypes.Property) + { + continue; + } + + if (ShouldSkipProperty(prop)) + { + continue; + } + + WritePropertyRaw(writer, prop, options); + } + } + + /// + /// Writes a property value, treating nested objects as raw (Base properties only). + /// + private void WritePropertyRaw(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) + { + try + { + var value = prop.Value; + writer.WritePropertyName(prop.Name); + + if (LanguagePrimitives.IsNull(value)) + { + writer.WriteNullValue(); + } + else if (_maxDepth == 0 && !IsPrimitiveTypeOrNull(value)) + { + writer.WriteStringValue(value!.ToString()); + } + else + { + // Always treat nested values as raw objects (V1 compatible) + var psoValue = PSObject.AsPSObject(value); + SerializeRawValue(writer, psoValue, options); + } + } + catch + { + // Skip properties that throw on access + } + } + private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) { try @@ -483,9 +661,19 @@ private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSeria } else { - // Wrap value in PSObject to ensure custom converters are applied - var psoValue = PSObject.AsPSObject(value); - System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); + // Check if value was originally a PSObject (preserves Extended/Adapted properties) + // or a raw .NET object (serialize with Base properties only for V1 compatibility) + if (value is PSObject psoValue) + { + // Existing PSObject: use standard serialization (Extended | Adapted) + System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); + } + else + { + // Raw object: serialize with Base properties only (V1 compatible) + var pso = PSObject.AsPSObject(value); + SerializeRawValue(writer, pso, options); + } } } catch diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 index b71195f706a..5ba27118771 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 @@ -21,20 +21,17 @@ Describe 'ConvertTo-Json' -tags "CI" { $jsonFormat | Should -Match '"TestValue3": 99999' } - It "StopProcessing should succeed" -Pending:$true { + It "StopProcessing should succeed" -Pending:$true { $ps = [PowerShell]::Create() $null = $ps.AddScript({ $obj = [PSCustomObject]@{P1 = ''; P2 = ''; P3 = ''; P4 = ''; P5 = ''; P6 = ''} $obj.P1 = $obj.P2 = $obj.P3 = $obj.P4 = $obj.P5 = $obj.P6 = $obj 1..100 | ForEach-Object { $obj } | ConvertTo-Json -Depth 10 -Verbose - # the conversion is expected to take some time, this throw is in case it doesn't throw "Should not have thrown exception" }) $null = $ps.BeginInvoke() - # wait for verbose message from ConvertTo-Json to ensure cmdlet is processing Wait-UntilTrue { $ps.Streams.Verbose.Count -gt 0 } | Should -BeTrue $null = $ps.BeginStop($null, $null) - # wait a bit to ensure state has changed, not using synchronous Stop() to avoid blocking Pester Start-Sleep -Milliseconds 100 $ps.InvocationStateInfo.State | Should -BeExactly "Stopped" $ps.Dispose() @@ -57,15 +54,12 @@ Describe 'ConvertTo-Json' -tags "CI" { Bool = $False } - $ExpectedOutput = '{ - "FirstLevel1": "System.Collections.Hashtable", - "FirstLevel2": "System.Collections.Hashtable", - "Integer": 10, - "Bool": false -}' - $output = $ComplexObject | ConvertTo-Json -Depth 0 - $output | Should -Be $ExpectedOutput + $parsed = $output | ConvertFrom-Json + $parsed.FirstLevel1 | Should -BeExactly 'System.Collections.Hashtable' + $parsed.FirstLevel2 | Should -BeExactly 'System.Collections.Hashtable' + $parsed.Integer | Should -Be 10 + $parsed.Bool | Should -BeFalse } It "The result string is packed in an array symbols when AsArray parameter is used." { @@ -81,16 +75,16 @@ Describe 'ConvertTo-Json' -tags "CI" { $output | Should -BeExactly '1' } - It "The result string should ." -TestCases @( - @{name = "be not escaped by default."; params = @{}; expected = "{$newline ""abc"": ""'def'""$newline}" } - @{name = "be not escaped with '-EscapeHandling Default'."; params = @{EscapeHandling = 'Default'}; expected = "{$newline ""abc"": ""'def'""$newline}" } - @{name = "be escaped with '-EscapeHandling EscapeHtml'."; params = @{EscapeHandling = 'EscapeHtml'}; expected = "{$newline ""abc"": ""\u0027def\u0027""$newline}" } + It "The result string should ." -TestCases @( + @{name = "be not escaped by default"; params = @{}; pattern = '"abc":\s*"''def''"' } + @{name = "be not escaped with '-EscapeHandling Default'"; params = @{EscapeHandling = 'Default'}; pattern = '"abc":\s*"''def''"' } + @{name = "be escaped with '-EscapeHandling EscapeHtml'"; params = @{EscapeHandling = 'EscapeHtml'}; pattern = '\\u0027def\\u0027' } ) { - param ($name, $params ,$expected) + param ($name, $params, $pattern) - @{ 'abc' = "'def'" } | ConvertTo-Json @params | Should -BeExactly $expected + $json = @{ 'abc' = "'def'" } | ConvertTo-Json @params + $json | Should -Match $pattern } - It "Should handle null" { [pscustomobject] @{ prop=$null } | ConvertTo-Json -Compress | Should -BeExactly '{"prop":null}' $null | ConvertTo-Json -Compress | Should -Be 'null' @@ -102,19 +96,10 @@ Describe 'ConvertTo-Json' -tags "CI" { [ordered]@{ a = $null; b = [System.Management.Automation.Internal.AutomationNull]::Value; - c = [System.DBNull]::Value; - d = [NullString]::Value - } | ConvertTo-Json -Compress | Should -BeExactly '{"a":null,"b":null,"c":null,"d":null}' + } | ConvertTo-Json -Compress | Should -BeExactly '{"a":null,"b":null}' ConvertTo-Json ([System.Management.Automation.Internal.AutomationNull]::Value) | Should -BeExactly 'null' ConvertTo-Json ([NullString]::Value) | Should -BeExactly 'null' - - ConvertTo-Json -Compress @( - $null, - [System.Management.Automation.Internal.AutomationNull]::Value, - [System.DBNull]::Value, - [NullString]::Value - ) | Should -BeExactly '[null,null,null,null]' } It "Should handle the ETS properties added to 'DBNull.Value' and 'NullString.Value'" { @@ -123,7 +108,9 @@ Describe 'ConvertTo-Json' -tags "CI" { $p1 = Add-Member -InputObject ([System.DBNull]::Value) -MemberType NoteProperty -Name dbnull -Value 'dbnull' -PassThru $p2 = Add-Member -InputObject ([NullString]::Value) -MemberType NoteProperty -Name nullstr -Value 'nullstr' -PassThru - $p1, $p2 | ConvertTo-Json -Compress | Should -BeExactly '[{"value":null,"dbnull":"dbnull"},{"value":null,"nullstr":"nullstr"}]' + $result = $p1, $p2 | ConvertTo-Json -Compress | ConvertFrom-Json + $result[0].dbnull | Should -BeExactly 'dbnull' + $result[1].nullstr | Should -BeExactly 'nullstr' } finally { @@ -136,9 +123,8 @@ Describe 'ConvertTo-Json' -tags "CI" { $date = "2021-06-24T15:54:06.796999-07:00" $d = [DateTime]::Parse($date) - # need to use wildcard here due to some systems may be configured with different culture setting showing time in different format - $d | ConvertTo-Json -Compress | Should -BeLike '"2021-06-24T*' - $d | ConvertTo-Json | ConvertFrom-Json | Should -Be $d + $result = $d | ConvertTo-Json | ConvertFrom-Json + $result | Should -Be $d } It 'Should not serialize ETS properties added to String' { @@ -157,39 +143,41 @@ Describe 'ConvertTo-Json' -tags "CI" { $actual | Should -Be '{"Positive":18446744073709551615,"Negative":-18446744073709551615}' } - It 'Should serialize Uri correctly' { - $uri = [uri]"https://example.com/path" - $json = $uri | ConvertTo-Json -Compress - $json | Should -BeExactly '"https://example.com/path"' - } + It 'Should serialize special floating-point values as strings' { + $result = [double]::PositiveInfinity | ConvertTo-Json + $result | Should -BeIn @('"Infinity"', '"∞"') - It 'Should serialize enums as numbers by default' { - $json = [System.DayOfWeek]::Monday | ConvertTo-Json - $json | Should -BeExactly '1' + $result = [double]::NegativeInfinity | ConvertTo-Json + $result | Should -BeIn @('"-Infinity"', '"-∞"') + + $result = [double]::NaN | ConvertTo-Json + $result | Should -BeIn @('"NaN"', '"非数値 (NaN)"') } - It 'Should serialize enums as strings with -EnumsAsStrings' { - $json = [System.DayOfWeek]::Monday | ConvertTo-Json -EnumsAsStrings - $json | Should -BeExactly '"Monday"' + It 'Should return null for empty array' { + @() | ConvertTo-Json | Should -BeNull } - It 'Should serialize null correctly' { - $null | ConvertTo-Json | Should -BeExactly 'null' + It 'Should serialize SwitchParameter as object with IsPresent property' { + @{ flag = [switch]$true } | ConvertTo-Json -Compress | Should -BeExactly '{"flag":{"IsPresent":true}}' + @{ flag = [switch]$false } | ConvertTo-Json -Compress | Should -BeExactly '{"flag":{"IsPresent":false}}' } - It 'Should serialize arrays correctly' { - $arr = @(1, 2, 3) - $json = $arr | ConvertTo-Json -Compress - $json | Should -BeExactly '[1,2,3]' + It 'Should serialize Uri correctly' { + $uri = [uri]"https://example.com/path" + $json = $uri | ConvertTo-Json -Compress + $json | Should -BeExactly '"https://example.com/path"' } - It 'Should serialize hashtable correctly' { - $hash = [ordered]@{ a = 1; b = 2 } - $json = $hash | ConvertTo-Json -Compress - $json | Should -BeExactly '{"a":1,"b":2}' + It 'Should serialize enums ' -TestCases @( + @{ description = 'as numbers by default'; params = @{}; expected = '1' } + @{ description = 'as strings with -EnumsAsStrings'; params = @{ EnumsAsStrings = $true }; expected = '"Monday"' } + ) { + param($description, $params, $expected) + [System.DayOfWeek]::Monday | ConvertTo-Json @params | Should -BeExactly $expected } - It 'Should serialize nested objects correctly' { + It 'Should serialize nested PSCustomObject correctly' { $obj = [pscustomobject]@{ name = "test" child = [pscustomobject]@{ @@ -200,40 +188,118 @@ Describe 'ConvertTo-Json' -tags "CI" { $json | Should -BeExactly '{"name":"test","child":{"value":42}}' } - It 'Should not escape by default' { - $json = @{ text = "<>&" } | ConvertTo-Json -Compress + It 'Should not escape HTML tag characters by default' { + $json = @{ text = '<>&' } | ConvertTo-Json -Compress $json | Should -BeExactly '{"text":"<>&"}' } - It 'Should escape HTML with -EscapeHandling EscapeHtml' { - $json = @{ text = "<>&" } | ConvertTo-Json -Compress -EscapeHandling EscapeHtml - $json | Should -Match '\\u003C' - $json | Should -Match '\\u003E' - $json | Should -Match '\\u0026' + It 'Should escape with -EscapeHandling ' -TestCases @( + @{ description = 'HTML tag characters'; inputText = '<>&'; EscapeHandling = 'EscapeHtml'; pattern = '\\u003C.*\\u003E.*\\u0026' } + @{ description = 'non-ASCII characters'; inputText = '日本語'; EscapeHandling = 'EscapeNonAscii'; pattern = '\\u' } + ) { + param($description, $inputText, $EscapeHandling, $pattern) + $json = @{ text = $inputText } | ConvertTo-Json -Compress -EscapeHandling $EscapeHandling + $json | Should -Match $pattern } - It 'Should escape non-ASCII with -EscapeHandling EscapeNonAscii' { - $json = @{ text = "日本語" } | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii - $json | Should -Match '\\u' + It 'Depth over 100 should throw' { + { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw } - It 'Depth parameter should work' { - $obj = @{ a = @{ b = 1 } } - $json = $obj | ConvertTo-Json -Depth 2 -Compress - $json | Should -BeExactly '{"a":{"b":1}}' - } + Context 'Nested raw object serialization' { + BeforeAll { + class TestClassWithFileInfo { + [System.IO.FileInfo]$File + } - It 'AsArray parameter should work' { - $json = @{a=1} | ConvertTo-Json -AsArray -Compress - $json | Should -BeExactly '[{"a":1}]' - } + $script:testFilePath = Join-Path $PSHOME 'pwsh.dll' + if (-not (Test-Path $script:testFilePath)) { + $script:testFilePath = Join-Path $PSHOME 'System.Management.Automation.dll' + } + } - It 'Multiple objects from pipeline should be serialized as array' { - $json = 1, 2, 3 | ConvertTo-Json -Compress - $json | Should -BeExactly '[1,2,3]' - } + It 'Typed property with raw FileInfo should serialize Base properties only' { + $obj = [TestClassWithFileInfo]::new() + $obj.File = [System.IO.FileInfo]::new($script:testFilePath) - It 'Depth over 100 should throw' { - { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw + $json = $obj | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed.File.PSObject.Properties.Name.Count | Should -Be 17 + } + + It 'Typed property loses PSObject wrapper from Get-Item' { + $obj = [TestClassWithFileInfo]::new() + $obj.File = Get-Item $script:testFilePath + + $json = $obj | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed.File.PSObject.Properties.Name.Count | Should -Be 17 + } + + It 'PSCustomObject with Get-Item preserves Extended properties' { + $obj = [PSCustomObject]@{ + File = Get-Item $script:testFilePath + } + + $json = $obj | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed.File.PSObject.Properties.Name.Count | Should -BeGreaterThan 17 + $parsed.File.PSObject.Properties.Name | Should -Contain 'PSPath' + } + + It 'PSCustomObject with raw FileInfo should serialize Base properties only' { + $obj = [PSCustomObject]@{ + File = [System.IO.FileInfo]::new($script:testFilePath) + } + + $json = $obj | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed.File.PSObject.Properties.Name.Count | Should -Be 17 + $parsed.File.PSObject.Properties.Name | Should -Not -Contain 'PSPath' + } + + It 'Hashtable with raw FileInfo should serialize Base properties only' { + $hash = @{ + File = [System.IO.FileInfo]::new($script:testFilePath) + } + + $json = $hash | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed.File.PSObject.Properties.Name.Count | Should -Be 17 + } + + It 'Hashtable with Get-Item preserves Extended properties' { + $hash = @{ + File = Get-Item $script:testFilePath + } + + $json = $hash | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed.File.PSObject.Properties.Name.Count | Should -BeGreaterThan 17 + } + + It 'Array of raw FileInfo should serialize with Adapted properties' { + $arr = @([System.IO.FileInfo]::new($script:testFilePath)) + + $json = $arr | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed[0].PSObject.Properties.Name.Count | Should -Be 24 + } + + It 'Array of Get-Item FileInfo preserves Extended properties' { + $arr = @(Get-Item $script:testFilePath) + + $json = $arr | ConvertTo-Json -Depth 2 + $parsed = $json | ConvertFrom-Json + + $parsed[0].PSObject.Properties.Name.Count | Should -BeGreaterThan 24 + } } } diff --git a/test/xUnit/csharp/test_ConvertToJson.cs b/test/xUnit/csharp/test_ConvertToJson.cs new file mode 100644 index 00000000000..f07903a0d82 --- /dev/null +++ b/test/xUnit/csharp/test_ConvertToJson.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerShell.Commands; +using Xunit; + +namespace PSTests.Parallel +{ + public static class ConvertToJsonTests + { + /// + /// Verifies that JsonStringEscapeHandling enum values match Newtonsoft.Json.StringEscapeHandling + /// for backward compatibility. These values must not change. + /// + [Fact] + public static void JsonStringEscapeHandling_Values_MatchNewtonsoftStringEscapeHandling() + { + Assert.Equal((int)Newtonsoft.Json.StringEscapeHandling.Default, (int)JsonStringEscapeHandling.Default); + Assert.Equal((int)Newtonsoft.Json.StringEscapeHandling.EscapeNonAscii, (int)JsonStringEscapeHandling.EscapeNonAscii); + Assert.Equal((int)Newtonsoft.Json.StringEscapeHandling.EscapeHtml, (int)JsonStringEscapeHandling.EscapeHtml); + } + } +} From c7b7ab1f4b3c194b3d4e087e6d38a69e1b11c58c Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 24 Dec 2025 23:48:00 +0900 Subject: [PATCH 35/42] Sync ConvertTo-Json.Tests.ps1 with PR #26639 --- .../ConvertTo-Json.Tests.ps1 | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 index 5ba27118771..43a430a5316 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 @@ -21,17 +21,20 @@ Describe 'ConvertTo-Json' -tags "CI" { $jsonFormat | Should -Match '"TestValue3": 99999' } - It "StopProcessing should succeed" -Pending:$true { + It "StopProcessing should succeed" -Pending:$true { $ps = [PowerShell]::Create() $null = $ps.AddScript({ $obj = [PSCustomObject]@{P1 = ''; P2 = ''; P3 = ''; P4 = ''; P5 = ''; P6 = ''} $obj.P1 = $obj.P2 = $obj.P3 = $obj.P4 = $obj.P5 = $obj.P6 = $obj 1..100 | ForEach-Object { $obj } | ConvertTo-Json -Depth 10 -Verbose + # the conversion is expected to take some time, this throw is in case it doesn't throw "Should not have thrown exception" }) $null = $ps.BeginInvoke() + # wait for verbose message from ConvertTo-Json to ensure cmdlet is processing Wait-UntilTrue { $ps.Streams.Verbose.Count -gt 0 } | Should -BeTrue $null = $ps.BeginStop($null, $null) + # wait a bit to ensure state has changed, not using synchronous Stop() to avoid blocking Pester Start-Sleep -Milliseconds 100 $ps.InvocationStateInfo.State | Should -BeExactly "Stopped" $ps.Dispose() @@ -54,12 +57,15 @@ Describe 'ConvertTo-Json' -tags "CI" { Bool = $False } + $ExpectedOutput = '{ + "FirstLevel1": "System.Collections.Hashtable", + "FirstLevel2": "System.Collections.Hashtable", + "Integer": 10, + "Bool": false +}' + $output = $ComplexObject | ConvertTo-Json -Depth 0 - $parsed = $output | ConvertFrom-Json - $parsed.FirstLevel1 | Should -BeExactly 'System.Collections.Hashtable' - $parsed.FirstLevel2 | Should -BeExactly 'System.Collections.Hashtable' - $parsed.Integer | Should -Be 10 - $parsed.Bool | Should -BeFalse + $output | Should -Be $ExpectedOutput } It "The result string is packed in an array symbols when AsArray parameter is used." { @@ -75,16 +81,16 @@ Describe 'ConvertTo-Json' -tags "CI" { $output | Should -BeExactly '1' } - It "The result string should ." -TestCases @( - @{name = "be not escaped by default"; params = @{}; pattern = '"abc":\s*"''def''"' } - @{name = "be not escaped with '-EscapeHandling Default'"; params = @{EscapeHandling = 'Default'}; pattern = '"abc":\s*"''def''"' } - @{name = "be escaped with '-EscapeHandling EscapeHtml'"; params = @{EscapeHandling = 'EscapeHtml'}; pattern = '\\u0027def\\u0027' } + It "The result string should ." -TestCases @( + @{name = "be not escaped by default."; params = @{}; expected = "{$newline ""abc"": ""'def'""$newline}" } + @{name = "be not escaped with '-EscapeHandling Default'."; params = @{EscapeHandling = 'Default'}; expected = "{$newline ""abc"": ""'def'""$newline}" } + @{name = "be escaped with '-EscapeHandling EscapeHtml'."; params = @{EscapeHandling = 'EscapeHtml'}; expected = "{$newline ""abc"": ""\u0027def\u0027""$newline}" } ) { - param ($name, $params, $pattern) + param ($name, $params ,$expected) - $json = @{ 'abc' = "'def'" } | ConvertTo-Json @params - $json | Should -Match $pattern + @{ 'abc' = "'def'" } | ConvertTo-Json @params | Should -BeExactly $expected } + It "Should handle null" { [pscustomobject] @{ prop=$null } | ConvertTo-Json -Compress | Should -BeExactly '{"prop":null}' $null | ConvertTo-Json -Compress | Should -Be 'null' @@ -96,10 +102,19 @@ Describe 'ConvertTo-Json' -tags "CI" { [ordered]@{ a = $null; b = [System.Management.Automation.Internal.AutomationNull]::Value; - } | ConvertTo-Json -Compress | Should -BeExactly '{"a":null,"b":null}' + c = [System.DBNull]::Value; + d = [NullString]::Value + } | ConvertTo-Json -Compress | Should -BeExactly '{"a":null,"b":null,"c":null,"d":null}' ConvertTo-Json ([System.Management.Automation.Internal.AutomationNull]::Value) | Should -BeExactly 'null' ConvertTo-Json ([NullString]::Value) | Should -BeExactly 'null' + + ConvertTo-Json -Compress @( + $null, + [System.Management.Automation.Internal.AutomationNull]::Value, + [System.DBNull]::Value, + [NullString]::Value + ) | Should -BeExactly '[null,null,null,null]' } It "Should handle the ETS properties added to 'DBNull.Value' and 'NullString.Value'" { @@ -108,9 +123,7 @@ Describe 'ConvertTo-Json' -tags "CI" { $p1 = Add-Member -InputObject ([System.DBNull]::Value) -MemberType NoteProperty -Name dbnull -Value 'dbnull' -PassThru $p2 = Add-Member -InputObject ([NullString]::Value) -MemberType NoteProperty -Name nullstr -Value 'nullstr' -PassThru - $result = $p1, $p2 | ConvertTo-Json -Compress | ConvertFrom-Json - $result[0].dbnull | Should -BeExactly 'dbnull' - $result[1].nullstr | Should -BeExactly 'nullstr' + $p1, $p2 | ConvertTo-Json -Compress | Should -BeExactly '[{"value":null,"dbnull":"dbnull"},{"value":null,"nullstr":"nullstr"}]' } finally { @@ -123,8 +136,9 @@ Describe 'ConvertTo-Json' -tags "CI" { $date = "2021-06-24T15:54:06.796999-07:00" $d = [DateTime]::Parse($date) - $result = $d | ConvertTo-Json | ConvertFrom-Json - $result | Should -Be $d + # need to use wildcard here due to some systems may be configured with different culture setting showing time in different format + $d | ConvertTo-Json -Compress | Should -BeLike '"2021-06-24T*' + $d | ConvertTo-Json | ConvertFrom-Json | Should -Be $d } It 'Should not serialize ETS properties added to String' { @@ -144,14 +158,9 @@ Describe 'ConvertTo-Json' -tags "CI" { } It 'Should serialize special floating-point values as strings' { - $result = [double]::PositiveInfinity | ConvertTo-Json - $result | Should -BeIn @('"Infinity"', '"∞"') - - $result = [double]::NegativeInfinity | ConvertTo-Json - $result | Should -BeIn @('"-Infinity"', '"-∞"') - - $result = [double]::NaN | ConvertTo-Json - $result | Should -BeIn @('"NaN"', '"非数値 (NaN)"') + [double]::PositiveInfinity | ConvertTo-Json | Should -BeExactly '"Infinity"' + [double]::NegativeInfinity | ConvertTo-Json | Should -BeExactly '"-Infinity"' + [double]::NaN | ConvertTo-Json | Should -BeExactly '"NaN"' } It 'Should return null for empty array' { From aa9a89f88ecd866268668aca924931a9b549ef59 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Thu, 25 Dec 2025 17:50:23 +0900 Subject: [PATCH 36/42] Replace hardcoded IsPrimitiveType with dynamic STJ native scalar detection --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 608 ++++++++++-------- 1 file changed, 357 insertions(+), 251 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index c95cfe356cb..7cbc62bd666 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -5,10 +5,9 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -using System.IO; -using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; using System.Numerics; @@ -208,10 +207,19 @@ internal static class SystemTextJsonSerializer options.Converters.Add(new JsonConverterNullString()); options.Converters.Add(new JsonConverterDBNull()); options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth)); + options.Converters.Add(new JsonConverterRawObject(cmdlet, maxDepth)); options.Converters.Add(new JsonConverterJObject()); - var pso = PSObject.AsPSObject(objectToProcess); - return System.Text.Json.JsonSerializer.Serialize(pso, typeof(PSObject), options); + // Distinguish between PSObject (Extended/Adapted properties) and raw object (Base only) + if (objectToProcess is PSObject pso) + { + return System.Text.Json.JsonSerializer.Serialize(pso, typeof(PSObject), options); + } + else + { + var wrapper = new RawObjectWrapper(objectToProcess); + return System.Text.Json.JsonSerializer.Serialize(wrapper, typeof(RawObjectWrapper), options); + } } catch (OperationCanceledException) { @@ -273,7 +281,7 @@ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerO bool wroteStart = false; foreach (var prop in etsProperties) { - if (!ShouldSkipProperty(prop)) + if (!JsonSerializerHelper.ShouldSkipProperty(prop)) { if (!wroteStart) { @@ -306,14 +314,14 @@ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerO return; } - // If PSObject wraps a primitive type, serialize the base object directly (no depth increment) - if (IsPrimitiveType(obj)) + // If STJ natively serializes this type as scalar, use STJ directly (no depth increment) + if (JsonSerializerHelper.IsStjNativeScalarType(obj)) { - SerializePrimitive(writer, obj, options); + JsonSerializerHelper.SerializePrimitive(writer, obj, options); return; } - // Check depth limit for complex types only (after primitive check) + // Check depth limit for complex types only (after scalar type check) if (currentDepth > _maxDepth) { WriteDepthExceeded(writer, pso, obj); @@ -343,7 +351,7 @@ private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) { _warningWritten = true; string warningMessage = string.Format( - System.Globalization.CultureInfo.CurrentCulture, + CultureInfo.CurrentCulture, "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.", _maxDepth); _cmdlet.WriteWarning(warningMessage); @@ -375,72 +383,6 @@ private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, writer.WriteEndArray(); } - private static bool IsPrimitiveType(object obj) - { - var type = obj.GetType(); - return type.IsPrimitive - || type.IsEnum - || obj is string - || obj is decimal - || obj is DateTime - || obj is DateTimeOffset - || obj is Guid - || obj is Uri - || obj is BigInteger; - } - - private static bool IsPrimitiveTypeOrNull(object? obj) - { - return obj is null || IsPrimitiveType(obj); - } - - private static void SerializePrimitive(Utf8JsonWriter writer, object obj, JsonSerializerOptions options) - { - // Handle special floating-point values (Infinity, NaN) as strings for V1 compatibility - if (obj is double d) - { - if (double.IsPositiveInfinity(d)) - { - writer.WriteStringValue("Infinity"); - return; - } - - if (double.IsNegativeInfinity(d)) - { - writer.WriteStringValue("-Infinity"); - return; - } - - if (double.IsNaN(d)) - { - writer.WriteStringValue("NaN"); - return; - } - } - else if (obj is float f) - { - if (float.IsPositiveInfinity(f)) - { - writer.WriteStringValue("Infinity"); - return; - } - - if (float.IsNegativeInfinity(f)) - { - writer.WriteStringValue("-Infinity"); - return; - } - - if (float.IsNaN(f)) - { - writer.WriteStringValue("NaN"); - return; - } - } - - System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); - } - private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options) { writer.WriteStartObject(); @@ -465,26 +407,27 @@ private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOpti { writer.WriteNullValue(); } - else if (IsPrimitiveType(value)) - { - SerializePrimitive(writer, value, options); - } else if (value is Newtonsoft.Json.Linq.JObject jObject) { System.Text.Json.JsonSerializer.Serialize(writer, jObject, options); } + else if (value is PSObject psoValue) + { + // Existing PSObject: use PSObject serialization (Extended/Adapted properties) + Write(writer, psoValue, options); + } else { - // Check if value was originally a PSObject (preserves Extended/Adapted properties) - // or a raw .NET object (serialize with Base properties only for V1 compatibility) - if (value is PSObject psoValue) + // Raw object: check if STJ natively handles this type + if (JsonSerializerHelper.IsStjNativeScalarType(value)) { - Write(writer, psoValue, options); + // STJ handles this type natively as scalar + JsonSerializerHelper.SerializePrimitive(writer, value, options); } else { - var pso = PSObject.AsPSObject(value); - SerializeRawValue(writer, pso, options); + // Not a native scalar type - delegate to JsonConverterRawObject (Base properties only) + System.Text.Json.JsonSerializer.Serialize(writer, new RawObjectWrapper(value), typeof(RawObjectWrapper), options); } } } @@ -496,76 +439,6 @@ private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializ writer.WriteEndObject(); } - /// - /// Serializes a raw .NET object with Base properties only (V1 compatible behavior). - /// - private void SerializeRawValue(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) - { - object obj = pso.BaseObject; - - // Primitive types: serialize directly - if (IsPrimitiveType(obj)) - { - SerializePrimitive(writer, obj, options); - return; - } - - // Check depth limit - int currentDepth = writer.CurrentDepth; - if (currentDepth > _maxDepth) - { - WriteDepthExceeded(writer, pso, obj); - return; - } - - // Dictionary: serialize with standard dictionary handling - if (obj is IDictionary dict) - { - SerializeDictionary(writer, pso, dict, options); - return; - } - - // Enumerable: serialize with raw item handling - if (obj is IEnumerable enumerable) - { - SerializeEnumerableRaw(writer, enumerable, options); - return; - } - - // Object: serialize with Base properties only (MemberType == Property) - writer.WriteStartObject(); - AppendBaseProperties(writer, pso, options); - writer.WriteEndObject(); - } - - /// - /// Serializes an enumerable with raw .NET object handling (Base properties only). - /// - private void SerializeEnumerableRaw(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) - { - writer.WriteStartArray(); - foreach (var item in enumerable) - { - if (item is null) - { - writer.WriteNullValue(); - } - else if (item is PSObject psoItem) - { - // Existing PSObject: use standard serialization - Write(writer, psoItem, options); - } - else - { - // Raw object: serialize with Base properties only - var pso = PSObject.AsPSObject(item); - SerializeRawValue(writer, pso, options); - } - } - - writer.WriteEndArray(); - } - private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, PSMemberViewTypes memberTypes) { var properties = new PSMemberInfoIntegratingCollection( @@ -574,7 +447,7 @@ private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSeriali foreach (var prop in properties) { - if (ShouldSkipProperty(prop)) + if (JsonSerializerHelper.ShouldSkipProperty(prop)) { continue; } @@ -583,64 +456,6 @@ private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSeriali } } - /// - /// Appends only base .NET properties (MemberType == Property) for V1 compatibility. - /// - private void AppendBaseProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) - { - // Use Adapted view which includes .NET properties via DotNetAdapter - var properties = new PSMemberInfoIntegratingCollection( - pso, - PSObject.GetPropertyCollection(PSMemberViewTypes.Adapted)); - - foreach (var prop in properties) - { - // Filter to only Property type (excludes CodeProperty, ScriptProperty, etc.) - if (prop.MemberType != PSMemberTypes.Property) - { - continue; - } - - if (ShouldSkipProperty(prop)) - { - continue; - } - - WritePropertyRaw(writer, prop, options); - } - } - - /// - /// Writes a property value, treating nested objects as raw (Base properties only). - /// - private void WritePropertyRaw(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) - { - try - { - var value = prop.Value; - writer.WritePropertyName(prop.Name); - - if (LanguagePrimitives.IsNull(value)) - { - writer.WriteNullValue(); - } - else if (_maxDepth == 0 && !IsPrimitiveTypeOrNull(value)) - { - writer.WriteStringValue(value!.ToString()); - } - else - { - // Always treat nested values as raw objects (V1 compatible) - var psoValue = PSObject.AsPSObject(value); - SerializeRawValue(writer, psoValue, options); - } - } - catch - { - // Skip properties that throw on access - } - } - private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) { try @@ -654,26 +469,20 @@ private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSeria writer.WriteNullValue(); } - // If maxDepth is 0, convert non-primitive values to string - else if (_maxDepth == 0 && !IsPrimitiveTypeOrNull(value)) + // If maxDepth is 0, convert non-scalar values to string + else if (_maxDepth == 0 && !JsonSerializerHelper.IsStjNativeScalarType(value)) { writer.WriteStringValue(value!.ToString()); } + else if (value is PSObject psoValue) + { + // Existing PSObject: use PSObject serialization (Extended/Adapted properties) + System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); + } else { - // Check if value was originally a PSObject (preserves Extended/Adapted properties) - // or a raw .NET object (serialize with Base properties only for V1 compatibility) - if (value is PSObject psoValue) - { - // Existing PSObject: use standard serialization (Extended | Adapted) - System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); - } - else - { - // Raw object: serialize with Base properties only (V1 compatible) - var pso = PSObject.AsPSObject(value); - SerializeRawValue(writer, pso, options); - } + // Raw object: delegate to JsonConverterRawObject (Base properties only) + System.Text.Json.JsonSerializer.Serialize(writer, new RawObjectWrapper(value), typeof(RawObjectWrapper), options); } } catch @@ -681,27 +490,6 @@ private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSeria // Skip properties that throw on access - write nothing for this property } } - - private static bool ShouldSkipProperty(PSPropertyInfo prop) - { - // Check for Hidden attribute - if (prop.IsHidden) - { - return true; - } - - // Check for JsonIgnoreAttribute on the underlying member - if (prop is PSProperty psProperty) - { - if (psProperty.adapterData is MemberInfo memberInfo && - memberInfo.GetCustomAttribute() is not null) - { - return true; - } - } - - return false; - } } /// @@ -817,4 +605,322 @@ private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq System.Text.Json.JsonSerializer.Serialize(writer, value, options); } } + + /// + /// Wrapper class for raw .NET objects to distinguish them from PSObjects at the type level. + /// This enables separate JsonConverter handling for raw objects (Base properties only). + /// + internal sealed class RawObjectWrapper + { + public RawObjectWrapper(object value) + { + Value = value; + } + + public object Value { get; } + } + + /// + /// Custom JsonConverter for RawObjectWrapper that serializes with Base properties only (V1 compatible). + /// + internal sealed class JsonConverterRawObject : System.Text.Json.Serialization.JsonConverter + { + private readonly PSCmdlet? _cmdlet; + private readonly int _maxDepth; + + private bool _warningWritten; + + public JsonConverterRawObject(PSCmdlet? cmdlet, int maxDepth) + { + _cmdlet = cmdlet; + _maxDepth = maxDepth; + } + + public override RawObjectWrapper? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, RawObjectWrapper wrapper, JsonSerializerOptions options) + { + SerializeRaw(writer, wrapper.Value, options); + } + + private void SerializeRaw(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (LanguagePrimitives.IsNull(value) || value is DBNull or System.Management.Automation.Language.NullString) + { + writer.WriteNullValue(); + return; + } + + // Handle Newtonsoft.Json.Linq.JObject + if (value is Newtonsoft.Json.Linq.JObject jObject) + { + System.Text.Json.JsonSerializer.Serialize(writer, jObject, options); + return; + } + + // Types that STJ handles natively as scalar + if (JsonSerializerHelper.IsStjNativeScalarType(value)) + { + JsonSerializerHelper.SerializePrimitive(writer, value, options); + return; + } + + // Check depth limit + int currentDepth = writer.CurrentDepth; + if (currentDepth > _maxDepth) + { + WriteDepthExceeded(writer, value); + return; + } + + // Dictionary + if (value is IDictionary dict) + { + SerializeDictionary(writer, dict, options); + return; + } + + // Enumerable + if (value is IEnumerable enumerable) + { + SerializeEnumerable(writer, enumerable, options); + return; + } + + var pso = PSObject.AsPSObject(value); + // Object: serialize with Base properties only + writer.WriteStartObject(); + AppendBaseProperties(writer, pso, options); + writer.WriteEndObject(); + } + + private void WriteDepthExceeded(Utf8JsonWriter writer, object value) + { + if (!_warningWritten && _cmdlet is not null) + { + _warningWritten = true; + string warningMessage = string.Format( + CultureInfo.CurrentCulture, + "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.", + _maxDepth); + _cmdlet.WriteWarning(warningMessage); + } + + writer.WriteStringValue(LanguagePrimitives.ConvertTo(value)); + } + + private void SerializeDictionary(Utf8JsonWriter writer, IDictionary dict, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (DictionaryEntry entry in dict) + { + string key = entry.Key?.ToString() ?? string.Empty; + writer.WritePropertyName(key); + SerializeRaw(writer, entry.Value, options); + } + + writer.WriteEndObject(); + } + + private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var item in enumerable) + { + if (item is PSObject psoItem) + { + // Existing PSObject: use PSObject serialization (Extended/Adapted) + System.Text.Json.JsonSerializer.Serialize(writer, psoItem, typeof(PSObject), options); + } + else + { + // Raw object: serialize with Base properties only + SerializeRaw(writer, item, options); + } + } + + writer.WriteEndArray(); + } + + private void AppendBaseProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + // Use Adapted view and filter to Property type only. + // This gives us the .NET properties without ETS additions (CodeProperty, ScriptProperty, etc.) + var properties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(PSMemberViewTypes.Adapted)); + + foreach (var prop in properties) + { + // Filter to only Property type (excludes CodeProperty, ScriptProperty, etc.) + if (prop.MemberType != PSMemberTypes.Property) + { + continue; + } + + if (JsonSerializerHelper.ShouldSkipProperty(prop)) + { + continue; + } + + WriteProperty(writer, prop, options); + } + } + + private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) + { + try + { + var value = prop.Value; + writer.WritePropertyName(prop.Name); + + if (LanguagePrimitives.IsNull(value)) + { + writer.WriteNullValue(); + } + else if (_maxDepth == 0 && !JsonSerializerHelper.IsStjNativeScalarType(value)) + { + writer.WriteStringValue(value!.ToString()); + } + else if (value is PSObject psoValue) + { + // Existing PSObject: use PSObject serialization (Extended/Adapted) + System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); + } + else + { + // Raw object: serialize with Base properties only + SerializeRaw(writer, value, options); + } + } + catch + { + // Skip properties that throw on access + } + } + } + + /// + /// Shared helper methods for JSON serialization. + /// + internal static class JsonSerializerHelper + { + private static readonly ConcurrentDictionary s_stjNativeScalarTypeCache = new(); + + /// + /// Determines if STJ natively serializes the type as a scalar (string, number, boolean). + /// Results are cached per type for performance. The first instance of each type determines the cached result. + /// + public static bool IsStjNativeScalarType(object obj) + { + var type = obj.GetType(); + + // Special cases: types that need custom handling but should be treated as scalars + // BigInteger: STJ serializes as object, but V1 serializes as number + if (type == typeof(BigInteger)) + { + return true; + } + + // Infinity/NaN: STJ throws, but V1 serializes as string + if (obj is double d && (double.IsInfinity(d) || double.IsNaN(d))) + { + return true; + } + + if (obj is float f && (float.IsInfinity(f) || float.IsNaN(f))) + { + return true; + } + + return s_stjNativeScalarTypeCache.GetOrAdd(type, _ => + { + try + { + var json = System.Text.Json.JsonSerializer.Serialize(obj, type); + return json.Length > 0 && json[0] != '{' && json[0] != '['; + } + catch + { + return false; + } + }); + } + + public static void SerializePrimitive(Utf8JsonWriter writer, object obj, JsonSerializerOptions options) + { + // Handle special floating-point values (Infinity, NaN) as strings for V1 compatibility + if (obj is double d) + { + if (double.IsPositiveInfinity(d)) + { + writer.WriteStringValue("Infinity"); + return; + } + + if (double.IsNegativeInfinity(d)) + { + writer.WriteStringValue("-Infinity"); + return; + } + + if (double.IsNaN(d)) + { + writer.WriteStringValue("NaN"); + return; + } + } + else if (obj is float f) + { + if (float.IsPositiveInfinity(f)) + { + writer.WriteStringValue("Infinity"); + return; + } + + if (float.IsNegativeInfinity(f)) + { + writer.WriteStringValue("-Infinity"); + return; + } + + if (float.IsNaN(f)) + { + writer.WriteStringValue("NaN"); + return; + } + } + else if (obj is BigInteger bi) + { + writer.WriteRawValue(bi.ToString(CultureInfo.InvariantCulture)); + return; + } + + System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); + } + + public static bool ShouldSkipProperty(PSPropertyInfo prop) + { + // Check for Hidden attribute + if (prop.IsHidden) + { + return true; + } + + // Check for JsonIgnoreAttribute on the underlying member + if (prop is PSProperty psProperty) + { + if (psProperty.adapterData is MemberInfo memberInfo && + memberInfo.GetCustomAttribute() is not null) + { + return true; + } + } + + return false; + } + } } From ef7f737176704026becfa59bbdcdf6dc566c6ae4 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Thu, 25 Dec 2025 23:46:58 +0900 Subject: [PATCH 37/42] Add blank line before single-line comment per SA1515 --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 7cbc62bd666..411f7ae4dbc 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -691,6 +691,7 @@ private void SerializeRaw(Utf8JsonWriter writer, object? value, JsonSerializerOp } var pso = PSObject.AsPSObject(value); + // Object: serialize with Base properties only writer.WriteStartObject(); AppendBaseProperties(writer, pso, options); From 385cf51c8fe676ff52a431d28854d177aacc04f1 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Fri, 26 Dec 2025 23:59:21 +0900 Subject: [PATCH 38/42] Unify JsonConverterPSObject and JsonConverterRawObject with basePropertiesOnly flag --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 252 +++++------------- ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 26 +- .../ConvertTo-Json.Tests.ps1 | 45 ++++ 3 files changed, 125 insertions(+), 198 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 411f7ae4dbc..66a3f7feb03 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -244,13 +244,15 @@ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.Jso { private readonly PSCmdlet? _cmdlet; private readonly int _maxDepth; + private readonly bool _basePropertiesOnly; private bool _warningWritten; - public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth) + public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth, bool basePropertiesOnly = false) { _cmdlet = cmdlet; _maxDepth = maxDepth; + _basePropertiesOnly = basePropertiesOnly; } public override PSObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -271,37 +273,39 @@ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerO int currentDepth = writer.CurrentDepth; // Handle special types - check for null-like objects (no depth increment needed) - if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) + if (obj is null || obj is DBNull or System.Management.Automation.Language.NullString) { // Single enumeration: write properties directly as we find them var etsProperties = new PSMemberInfoIntegratingCollection( pso, PSObject.GetPropertyCollection(PSMemberViewTypes.Extended)); - bool wroteStart = false; + bool isNull = true; foreach (var prop in etsProperties) { - if (!JsonSerializerHelper.ShouldSkipProperty(prop)) + if (JsonSerializerHelper.ShouldSkipProperty(prop)) { - if (!wroteStart) - { - writer.WriteStartObject(); - writer.WritePropertyName("value"); - writer.WriteNullValue(); - wroteStart = true; - } - - WriteProperty(writer, prop, options); + continue; } + + if (isNull) + { + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + isNull = false; + } + + WriteProperty(writer, prop, options); } - if (wroteStart) + if (isNull) { - writer.WriteEndObject(); + writer.WriteNullValue(); } else { - writer.WriteNullValue(); + writer.WriteEndObject(); } return; @@ -371,13 +375,16 @@ private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, { writer.WriteNullValue(); } - else + else if (item is PSObject psoItem) { - var psoItem = PSObject.AsPSObject(item); - - // Recursive call - Write will handle depth tracking + // Existing PSObject: use PSObject serialization (Extended/Adapted properties) Write(writer, psoItem, options); } + else + { + // Raw object: delegate to JsonConverterRawObject (Base properties only) + System.Text.Json.JsonSerializer.Serialize(writer, new RawObjectWrapper(item), typeof(RawObjectWrapper), options); + } } writer.WriteEndArray(); @@ -395,8 +402,11 @@ private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionar WriteValue(writer, entry.Value, options); } - // Add PSObject extended properties - AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended); + // Add PSObject extended properties (skip for base properties only mode) + if (!_basePropertiesOnly) + { + AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended); + } writer.WriteEndObject(); } @@ -435,11 +445,25 @@ private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOpti private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) { writer.WriteStartObject(); - AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted); + if (_basePropertiesOnly) + { + // Base properties only: use Adapted view and filter to Property type + AppendPSProperties(writer, pso, options, PSMemberViewTypes.Adapted, filterToPropertyOnly: true); + } + else + { + AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted); + } + writer.WriteEndObject(); } - private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options, PSMemberViewTypes memberTypes) + private void AppendPSProperties( + Utf8JsonWriter writer, + PSObject pso, + JsonSerializerOptions options, + PSMemberViewTypes memberTypes, + bool filterToPropertyOnly = false) { var properties = new PSMemberInfoIntegratingCollection( pso, @@ -447,6 +471,12 @@ private void AppendPSProperties(Utf8JsonWriter writer, PSObject pso, JsonSeriali foreach (var prop in properties) { + // Filter to only Property type if requested (excludes CodeProperty, ScriptProperty, etc.) + if (filterToPropertyOnly && prop.MemberType != PSMemberTypes.Property) + { + continue; + } + if (JsonSerializerHelper.ShouldSkipProperty(prop)) { continue; @@ -621,19 +651,19 @@ public RawObjectWrapper(object value) } /// - /// Custom JsonConverter for RawObjectWrapper that serializes with Base properties only (V1 compatible). + /// Custom JsonConverter for RawObjectWrapper that delegates to JsonConverterPSObject with base properties only. /// + /// + /// This converter wraps raw objects (non-PSObject) and serializes them with only base .NET properties, + /// excluding PowerShell extended/adapted properties. This maintains V1 compatibility for nested raw objects. + /// internal sealed class JsonConverterRawObject : System.Text.Json.Serialization.JsonConverter { - private readonly PSCmdlet? _cmdlet; - private readonly int _maxDepth; - - private bool _warningWritten; + private readonly JsonConverterPSObject _psoConverter; public JsonConverterRawObject(PSCmdlet? cmdlet, int maxDepth) { - _cmdlet = cmdlet; - _maxDepth = maxDepth; + _psoConverter = new JsonConverterPSObject(cmdlet, maxDepth, basePropertiesOnly: true); } public override RawObjectWrapper? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -643,164 +673,8 @@ public JsonConverterRawObject(PSCmdlet? cmdlet, int maxDepth) public override void Write(Utf8JsonWriter writer, RawObjectWrapper wrapper, JsonSerializerOptions options) { - SerializeRaw(writer, wrapper.Value, options); - } - - private void SerializeRaw(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) - { - if (LanguagePrimitives.IsNull(value) || value is DBNull or System.Management.Automation.Language.NullString) - { - writer.WriteNullValue(); - return; - } - - // Handle Newtonsoft.Json.Linq.JObject - if (value is Newtonsoft.Json.Linq.JObject jObject) - { - System.Text.Json.JsonSerializer.Serialize(writer, jObject, options); - return; - } - - // Types that STJ handles natively as scalar - if (JsonSerializerHelper.IsStjNativeScalarType(value)) - { - JsonSerializerHelper.SerializePrimitive(writer, value, options); - return; - } - - // Check depth limit - int currentDepth = writer.CurrentDepth; - if (currentDepth > _maxDepth) - { - WriteDepthExceeded(writer, value); - return; - } - - // Dictionary - if (value is IDictionary dict) - { - SerializeDictionary(writer, dict, options); - return; - } - - // Enumerable - if (value is IEnumerable enumerable) - { - SerializeEnumerable(writer, enumerable, options); - return; - } - - var pso = PSObject.AsPSObject(value); - - // Object: serialize with Base properties only - writer.WriteStartObject(); - AppendBaseProperties(writer, pso, options); - writer.WriteEndObject(); - } - - private void WriteDepthExceeded(Utf8JsonWriter writer, object value) - { - if (!_warningWritten && _cmdlet is not null) - { - _warningWritten = true; - string warningMessage = string.Format( - CultureInfo.CurrentCulture, - "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.", - _maxDepth); - _cmdlet.WriteWarning(warningMessage); - } - - writer.WriteStringValue(LanguagePrimitives.ConvertTo(value)); - } - - private void SerializeDictionary(Utf8JsonWriter writer, IDictionary dict, JsonSerializerOptions options) - { - writer.WriteStartObject(); - foreach (DictionaryEntry entry in dict) - { - string key = entry.Key?.ToString() ?? string.Empty; - writer.WritePropertyName(key); - SerializeRaw(writer, entry.Value, options); - } - - writer.WriteEndObject(); - } - - private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) - { - writer.WriteStartArray(); - foreach (var item in enumerable) - { - if (item is PSObject psoItem) - { - // Existing PSObject: use PSObject serialization (Extended/Adapted) - System.Text.Json.JsonSerializer.Serialize(writer, psoItem, typeof(PSObject), options); - } - else - { - // Raw object: serialize with Base properties only - SerializeRaw(writer, item, options); - } - } - - writer.WriteEndArray(); - } - - private void AppendBaseProperties(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) - { - // Use Adapted view and filter to Property type only. - // This gives us the .NET properties without ETS additions (CodeProperty, ScriptProperty, etc.) - var properties = new PSMemberInfoIntegratingCollection( - pso, - PSObject.GetPropertyCollection(PSMemberViewTypes.Adapted)); - - foreach (var prop in properties) - { - // Filter to only Property type (excludes CodeProperty, ScriptProperty, etc.) - if (prop.MemberType != PSMemberTypes.Property) - { - continue; - } - - if (JsonSerializerHelper.ShouldSkipProperty(prop)) - { - continue; - } - - WriteProperty(writer, prop, options); - } - } - - private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) - { - try - { - var value = prop.Value; - writer.WritePropertyName(prop.Name); - - if (LanguagePrimitives.IsNull(value)) - { - writer.WriteNullValue(); - } - else if (_maxDepth == 0 && !JsonSerializerHelper.IsStjNativeScalarType(value)) - { - writer.WriteStringValue(value!.ToString()); - } - else if (value is PSObject psoValue) - { - // Existing PSObject: use PSObject serialization (Extended/Adapted) - System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); - } - else - { - // Raw object: serialize with Base properties only - SerializeRaw(writer, value, options); - } - } - catch - { - // Skip properties that throw on access - } + var pso = PSObject.AsPSObject(wrapper.Value); + _psoConverter.Write(writer, pso, options); } } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index d0456d6708f..4909bc08ba8 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -6,18 +6,10 @@ BeforeDiscovery { } Describe 'ConvertTo-Json PSJsonSerializerV2 specific behavior' -Tags "CI" -Skip:(-not $isV2Enabled) { - It 'Should output warning when depth is exceeded' { - $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } - $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue - $json | Should -Not -BeNullOrEmpty - $warn | Should -Not -BeNullOrEmpty - } - It 'Should serialize dictionary with integer keys' { $dict = @{ 1 = "one"; 2 = "two" } $json = $dict | ConvertTo-Json -Compress - $json | Should -Match '"1":\s*"one"' - $json | Should -Match '"2":\s*"two"' + $json | Should -BeIn @('{"1":"one","2":"two"}', '{"2":"two","1":"one"}') } It 'Should serialize Exception.Data with non-string keys' { @@ -40,6 +32,7 @@ Describe 'ConvertTo-Json PSJsonSerializerV2 specific behavior' -Tags "CI" -Skip: $json | Should -Not -Match 'Hidden' } + # V2 improvement: Guid is serialized consistently (V1 had inconsistency between Pipeline and InputObject) It 'Should serialize Guid as string consistently' { $guid = [guid]"12345678-1234-1234-1234-123456789abc" $jsonPipeline = $guid | ConvertTo-Json -Compress @@ -47,4 +40,19 @@ Describe 'ConvertTo-Json PSJsonSerializerV2 specific behavior' -Tags "CI" -Skip: $jsonPipeline | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' $jsonInputObject | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' } + + # V2 design: Array elements use Base properties only (consistent behavior) + # This differs from V1 where array elements sometimes had Extended properties + Context 'Array element serialization' { + It 'Should serialize array elements with Base properties only' { + $file = Get-Item $PSHOME + $pso = [PSCustomObject]@{ Items = @($file) } + + $json = $pso | ConvertTo-Json -Depth 2 -Compress + # PSPath is an Extended property - V2 uses Base properties for array elements + $json | Should -Not -Match 'PSPath' + # Name is a Base property - should be present + $json | Should -Match '"Name"' + } + } } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 index 43a430a5316..b8fcadfadce 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 @@ -311,4 +311,49 @@ Describe 'ConvertTo-Json' -tags "CI" { $parsed[0].PSObject.Properties.Name.Count | Should -BeGreaterThan 24 } } + + Context 'STJ-native scalar types serialization consistency' { + # These types are serialized identically via Pipeline and InputObject + It 'Should serialize DateTime consistently via Pipeline and InputObject' { + $dt = [datetime]"2024-01-15T10:30:00" + $jsonPipeline = $dt | ConvertTo-Json -Compress + $jsonInputObject = ConvertTo-Json -InputObject $dt -Compress + $jsonPipeline | Should -BeExactly $jsonInputObject + } + + It 'Should serialize TimeSpan consistently via Pipeline and InputObject' { + $ts = [timespan]::FromHours(2.5) + $jsonPipeline = $ts | ConvertTo-Json -Compress + $jsonInputObject = ConvertTo-Json -InputObject $ts -Compress + $jsonPipeline | Should -BeExactly $jsonInputObject + } + + It 'Should serialize Version consistently via Pipeline and InputObject' { + $ver = [version]"1.2.3.4" + $jsonPipeline = $ver | ConvertTo-Json -Compress + $jsonInputObject = ConvertTo-Json -InputObject $ver -Compress + $jsonPipeline | Should -BeExactly $jsonInputObject + } + } + + Context 'PSObject vs raw object distinction' { + # Non-scalar types may differ between Pipeline (PSObject) and InputObject (raw) + # Pipeline wraps objects in PSObject, adding Extended/Adapted properties + It 'Should serialize IPAddress with Extended properties via Pipeline' { + $ip = [System.Net.IPAddress]::Parse("192.168.1.1") + $jsonPipeline = $ip | ConvertTo-Json -Compress + $jsonInputObject = ConvertTo-Json -InputObject $ip -Compress + # Pipeline includes IPAddressToString (Extended property) + $jsonPipeline | Should -Match 'IPAddressToString' + # InputObject does not include Extended properties + $jsonInputObject | Should -Not -Match 'IPAddressToString' + } + } + + It 'Should output warning when depth is exceeded' { + $a = @{ a = @{ b = @{ c = @{ d = 1 } } } } + $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue + $json | Should -Not -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + } } From 6913cb1197406fcf73466709b54292b367f312b2 Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sun, 28 Dec 2025 08:03:44 +0900 Subject: [PATCH 39/42] Address code review feedback: use custom converters and simplify SerializePrimitive --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 129 ++++++++++-------- ...onvertTo-Json.PSJsonSerializerV2.Tests.ps1 | 33 +++-- 2 files changed, 96 insertions(+), 66 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 66a3f7feb03..3c2cc813fd2 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -204,9 +204,11 @@ internal static class SystemTextJsonSerializer } options.Converters.Add(new JsonConverterBigInteger()); + options.Converters.Add(new JsonConverterDouble()); + options.Converters.Add(new JsonConverterFloat()); options.Converters.Add(new JsonConverterNullString()); options.Converters.Add(new JsonConverterDBNull()); - options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth)); + options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth, basePropertiesOnly: false)); options.Converters.Add(new JsonConverterRawObject(cmdlet, maxDepth)); options.Converters.Add(new JsonConverterJObject()); @@ -248,7 +250,7 @@ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.Jso private bool _warningWritten; - public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth, bool basePropertiesOnly = false) + public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth, bool basePropertiesOnly) { _cmdlet = cmdlet; _maxDepth = maxDepth; @@ -599,6 +601,68 @@ public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializ } } + /// + /// JsonConverter for double to serialize Infinity and NaN as strings for V1 compatibility. + /// + internal sealed class JsonConverterDouble : System.Text.Json.Serialization.JsonConverter + { + public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) + { + if (double.IsPositiveInfinity(value)) + { + writer.WriteStringValue("Infinity"); + } + else if (double.IsNegativeInfinity(value)) + { + writer.WriteStringValue("-Infinity"); + } + else if (double.IsNaN(value)) + { + writer.WriteStringValue("NaN"); + } + else + { + writer.WriteNumberValue(value); + } + } + } + + /// + /// JsonConverter for float to serialize Infinity and NaN as strings for V1 compatibility. + /// + internal sealed class JsonConverterFloat : System.Text.Json.Serialization.JsonConverter + { + public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) + { + if (float.IsPositiveInfinity(value)) + { + writer.WriteStringValue("Infinity"); + } + else if (float.IsNegativeInfinity(value)) + { + writer.WriteStringValue("-Infinity"); + } + else if (float.IsNaN(value)) + { + writer.WriteStringValue("NaN"); + } + else + { + writer.WriteNumberValue(value); + } + } + } + /// /// Custom JsonConverter for Newtonsoft.Json.Linq.JObject to isolate Newtonsoft-related code. /// @@ -638,16 +702,13 @@ private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq /// /// Wrapper class for raw .NET objects to distinguish them from PSObjects at the type level. - /// This enables separate JsonConverter handling for raw objects (Base properties only). + /// Inherits from PSObject to avoid rewrapping when passed to JsonConverterPSObject. /// - internal sealed class RawObjectWrapper + internal sealed class RawObjectWrapper : PSObject { - public RawObjectWrapper(object value) + public RawObjectWrapper(object value) : base(value) { - Value = value; } - - public object Value { get; } } /// @@ -673,8 +734,8 @@ public JsonConverterRawObject(PSCmdlet? cmdlet, int maxDepth) public override void Write(Utf8JsonWriter writer, RawObjectWrapper wrapper, JsonSerializerOptions options) { - var pso = PSObject.AsPSObject(wrapper.Value); - _psoConverter.Write(writer, pso, options); + // RawObjectWrapper inherits from PSObject, so no rewrapping needed + _psoConverter.Write(writer, wrapper, options); } } @@ -727,53 +788,7 @@ public static bool IsStjNativeScalarType(object obj) public static void SerializePrimitive(Utf8JsonWriter writer, object obj, JsonSerializerOptions options) { - // Handle special floating-point values (Infinity, NaN) as strings for V1 compatibility - if (obj is double d) - { - if (double.IsPositiveInfinity(d)) - { - writer.WriteStringValue("Infinity"); - return; - } - - if (double.IsNegativeInfinity(d)) - { - writer.WriteStringValue("-Infinity"); - return; - } - - if (double.IsNaN(d)) - { - writer.WriteStringValue("NaN"); - return; - } - } - else if (obj is float f) - { - if (float.IsPositiveInfinity(f)) - { - writer.WriteStringValue("Infinity"); - return; - } - - if (float.IsNegativeInfinity(f)) - { - writer.WriteStringValue("-Infinity"); - return; - } - - if (float.IsNaN(f)) - { - writer.WriteStringValue("NaN"); - return; - } - } - else if (obj is BigInteger bi) - { - writer.WriteRawValue(bi.ToString(CultureInfo.InvariantCulture)); - return; - } - + // Custom converters handle special cases (BigInteger, double/float Infinity/NaN) System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 index 4909bc08ba8..3d54ea083c9 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -1,22 +1,37 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -BeforeDiscovery { - $isV2Enabled = $EnabledExperimentalFeatures.Contains('PSJsonSerializerV2') -} +Describe 'ConvertTo-Json PSJsonSerializerV2 specific behavior' -Tags "CI" { + + BeforeAll { + $skipTest = -not $EnabledExperimentalFeatures.Contains('PSJsonSerializerV2') + + if ($skipTest) { + Write-Verbose "Test Suite Skipped. The test suite requires the experimental feature 'PSJsonSerializerV2' to be enabled." -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + } + } + + AfterAll { + if ($skipTest) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + } + } -Describe 'ConvertTo-Json PSJsonSerializerV2 specific behavior' -Tags "CI" -Skip:(-not $isV2Enabled) { It 'Should serialize dictionary with integer keys' { $dict = @{ 1 = "one"; 2 = "two" } $json = $dict | ConvertTo-Json -Compress $json | Should -BeIn @('{"1":"one","2":"two"}', '{"2":"two","1":"one"}') } - It 'Should serialize Exception.Data with non-string keys' { - $ex = [System.Exception]::new("test") - $ex.Data.Add(1, "value1") - $ex.Data.Add("key", "value2") - { $ex | ConvertTo-Json -Depth 1 } | Should -Not -Throw + It 'Should serialize dictionary with non-string keys converting keys to strings' { + $dict = [System.Collections.Hashtable]::new() + $dict.Add(1, "one") + $dict.Add([guid]"12345678-1234-1234-1234-123456789abc", "guid-value") + $json = $dict | ConvertTo-Json -Compress + $json | Should -Match '"1":\s*"one"' + $json | Should -Match '"12345678-1234-1234-1234-123456789abc":\s*"guid-value"' } It 'Should not serialize hidden properties in PowerShell class' { From da0c8cbfb7cd199cc20f2710d033e7d222f9af3e Mon Sep 17 00:00:00 2001 From: yotsuda Date: Sun, 28 Dec 2025 09:00:55 +0900 Subject: [PATCH 40/42] Remove unnecessary currentDepth local variable --- .../commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 3c2cc813fd2..04f6935120b 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -272,8 +272,6 @@ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerO object? obj = pso.BaseObject; - int currentDepth = writer.CurrentDepth; - // Handle special types - check for null-like objects (no depth increment needed) if (obj is null || obj is DBNull or System.Management.Automation.Language.NullString) { @@ -328,7 +326,7 @@ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerO } // Check depth limit for complex types only (after scalar type check) - if (currentDepth > _maxDepth) + if (writer.CurrentDepth > _maxDepth) { WriteDepthExceeded(writer, pso, obj); return; From 2ecc51e05d774954e4b564e77573d678e86f0b3e Mon Sep 17 00:00:00 2001 From: yotsuda Date: Tue, 30 Dec 2025 21:44:28 +0900 Subject: [PATCH 41/42] Use JsonTypeInfoKind for type classification and add JsonConverterType for System.Type --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 04f6935120b..12d5a71a2d3 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -16,6 +16,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Text.Unicode; using System.Threading; @@ -206,6 +207,7 @@ internal static class SystemTextJsonSerializer options.Converters.Add(new JsonConverterBigInteger()); options.Converters.Add(new JsonConverterDouble()); options.Converters.Add(new JsonConverterFloat()); + options.Converters.Add(new JsonConverterType()); options.Converters.Add(new JsonConverterNullString()); options.Converters.Add(new JsonConverterDBNull()); options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth, basePropertiesOnly: false)); @@ -504,6 +506,12 @@ private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSeria { writer.WriteStringValue(value!.ToString()); } + + // Scalar types: use SerializePrimitive (handles BigInteger, Type, etc.) + else if (JsonSerializerHelper.IsStjNativeScalarType(value)) + { + JsonSerializerHelper.SerializePrimitive(writer, value, options); + } else if (value is PSObject psoValue) { // Existing PSObject: use PSObject serialization (Extended/Adapted properties) @@ -661,6 +669,28 @@ public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOpt } } + /// + /// JsonConverter for System.Type to serialize as AssemblyQualifiedName string for V1 compatibility. + /// + internal sealed class JsonConverterType : System.Text.Json.Serialization.JsonConverter + { + public override bool CanConvert(Type typeToConvert) + { + // Handle Type and all derived types (e.g., RuntimeType) + return typeof(Type).IsAssignableFrom(typeToConvert); + } + + public override Type Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.AssemblyQualifiedName); + } + } + /// /// Custom JsonConverter for Newtonsoft.Json.Linq.JObject to isolate Newtonsoft-related code. /// @@ -746,7 +776,7 @@ internal static class JsonSerializerHelper /// /// Determines if STJ natively serializes the type as a scalar (string, number, boolean). - /// Results are cached per type for performance. The first instance of each type determines the cached result. + /// Uses JsonTypeInfoKind to classify types. Results are cached per type for performance. /// public static bool IsStjNativeScalarType(object obj) { @@ -759,6 +789,13 @@ public static bool IsStjNativeScalarType(object obj) return true; } + // System.Type: STJ reports JsonTypeInfoKind.None but cannot serialize it + // Use JsonConverterType to serialize as AssemblyQualifiedName (V1 compatibility) + if (typeof(Type).IsAssignableFrom(type)) + { + return true; + } + // Infinity/NaN: STJ throws, but V1 serializes as string if (obj is double d && (double.IsInfinity(d) || double.IsNaN(d))) { @@ -770,23 +807,19 @@ public static bool IsStjNativeScalarType(object obj) return true; } - return s_stjNativeScalarTypeCache.GetOrAdd(type, _ => + return s_stjNativeScalarTypeCache.GetOrAdd(type, static t => { - try - { - var json = System.Text.Json.JsonSerializer.Serialize(obj, type); - return json.Length > 0 && json[0] != '{' && json[0] != '['; - } - catch - { - return false; - } + var typeInfo = JsonSerializerOptions.Default.GetTypeInfo(t); + return typeInfo.Kind == JsonTypeInfoKind.None; }); } public static void SerializePrimitive(Utf8JsonWriter writer, object obj, JsonSerializerOptions options) { - // Custom converters handle special cases (BigInteger, double/float Infinity/NaN) + // Delegate to STJ - custom converters handle special cases: + // - JsonConverterBigInteger: BigInteger as number string + // - JsonConverterDouble/Float: Infinity/NaN as string + // - JsonConverterType: Type as AssemblyQualifiedName System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options); } From 2c549207a059fdab44c5b7733d7b3387e1f13bba Mon Sep 17 00:00:00 2001 From: yotsuda Date: Wed, 31 Dec 2025 09:16:35 +0900 Subject: [PATCH 42/42] Unify duplicate code in SerializeEnumerable and WriteProperty to use WriteValue --- .../WebCmdlet/ConvertToJsonCommandV2.cs | 44 +++---------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs index 12d5a71a2d3..f7567a4bea1 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -373,20 +373,7 @@ private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, writer.WriteStartArray(); foreach (var item in enumerable) { - if (item is null) - { - writer.WriteNullValue(); - } - else if (item is PSObject psoItem) - { - // Existing PSObject: use PSObject serialization (Extended/Adapted properties) - Write(writer, psoItem, options); - } - else - { - // Raw object: delegate to JsonConverterRawObject (Base properties only) - System.Text.Json.JsonSerializer.Serialize(writer, new RawObjectWrapper(item), typeof(RawObjectWrapper), options); - } + WriteValue(writer, item, options); } writer.WriteEndArray(); @@ -415,7 +402,8 @@ private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionar private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) { - if (value is null or DBNull or System.Management.Automation.Language.NullString) + // Handle null values (including AutomationNull) + if (LanguagePrimitives.IsNull(value) || value is DBNull) { writer.WriteNullValue(); } @@ -495,32 +483,14 @@ private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSeria var value = prop.Value; writer.WritePropertyName(prop.Name); - // Handle null values directly (including AutomationNull) - if (LanguagePrimitives.IsNull(value)) + // If maxDepth is 0 and value is non-null non-scalar, convert to string + if (_maxDepth == 0 && value is not null && !JsonSerializerHelper.IsStjNativeScalarType(value)) { - writer.WriteNullValue(); - } - - // If maxDepth is 0, convert non-scalar values to string - else if (_maxDepth == 0 && !JsonSerializerHelper.IsStjNativeScalarType(value)) - { - writer.WriteStringValue(value!.ToString()); - } - - // Scalar types: use SerializePrimitive (handles BigInteger, Type, etc.) - else if (JsonSerializerHelper.IsStjNativeScalarType(value)) - { - JsonSerializerHelper.SerializePrimitive(writer, value, options); - } - else if (value is PSObject psoValue) - { - // Existing PSObject: use PSObject serialization (Extended/Adapted properties) - System.Text.Json.JsonSerializer.Serialize(writer, psoValue, typeof(PSObject), options); + writer.WriteStringValue(value.ToString()); } else { - // Raw object: delegate to JsonConverterRawObject (Base properties only) - System.Text.Json.JsonSerializer.Serialize(writer, new RawObjectWrapper(value), typeof(RawObjectWrapper), options); + WriteValue(writer, value, options); } } catch