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..9ac93147651 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs @@ -15,6 +15,10 @@ 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 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..f7567a4bea1 --- /dev/null +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs @@ -0,0 +1,817 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +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.Json.Serialization.Metadata; +using System.Text.Unicode; +using System.Threading; + +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 + { + /// + /// 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) + => inputData is Newtonsoft.Json.StringEscapeHandling newtonsoftValue ? (JsonStringEscapeHandling)(int)newtonsoftValue : inputData; + } + + /// + /// The ConvertTo-Json command. + /// This command converts an object to a JSON string representation. + /// + /// + /// This class is shown when PSJsonSerializerV2 experimental feature is enabled. + /// + [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 + { + /// + /// Gets or sets the InputObject property. + /// + [Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true)] + [AllowNull] + public object? InputObject { get; set; } + + /// + /// Gets or sets the Depth property. + /// Default is 2. Maximum allowed is 100. + /// Use 0 to serialize only top-level properties. + /// + [Parameter] + [ValidateRange(0, 100)] + public int Depth { get; set; } = 2; + + /// + /// 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; } + + /// + /// 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. + /// + [Parameter] + [StringEscapeHandlingTransformation] + public JsonStringEscapeHandling EscapeHandling { get; set; } = JsonStringEscapeHandling.Default; + + 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 : _inputObjects[0]; + + string? output = SystemTextJsonSerializer.ConvertToJson( + objectToProcess, + Depth, + EnumsAsStrings.IsPresent, + Compress.IsPresent, + EscapeHandling, + this, + 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. + if (output is not null) + { + WriteObject(output); + } + } + } + } + + /// + /// 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, + JsonStringEscapeHandling stringEscapeHandling, + PSCmdlet? cmdlet, + CancellationToken cancellationToken) + { + if (objectToProcess is null) + { + return "null"; + } + + try + { + 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, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Encoder = GetEncoder(stringEscapeHandling), + }; + + if (enumsAsStrings) + { + options.Converters.Add(new JsonStringEnumConverter()); + } + + // Add custom converters for PowerShell-specific types + if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSSerializeJSONLongEnumAsNumber)) + { + options.Converters.Add(new JsonConverterInt64Enum()); + } + + 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)); + options.Converters.Add(new JsonConverterRawObject(cmdlet, maxDepth)); + options.Converters.Add(new JsonConverterJObject()); + + // 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) + { + return null; + } + } + + private static JavaScriptEncoder GetEncoder(JsonStringEscapeHandling escapeHandling) => + escapeHandling switch + { + JsonStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + JsonStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default, + JsonStringEscapeHandling.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; + private readonly bool _basePropertiesOnly; + + private bool _warningWritten; + + public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth, bool basePropertiesOnly) + { + _cmdlet = cmdlet; + _maxDepth = maxDepth; + _basePropertiesOnly = basePropertiesOnly; + } + + 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; + } + + object? obj = pso.BaseObject; + + // 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) + { + // Single enumeration: write properties directly as we find them + var etsProperties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(PSMemberViewTypes.Extended)); + + bool isNull = true; + foreach (var prop in etsProperties) + { + if (JsonSerializerHelper.ShouldSkipProperty(prop)) + { + continue; + } + + if (isNull) + { + writer.WriteStartObject(); + writer.WritePropertyName("value"); + writer.WriteNullValue(); + isNull = false; + } + + WriteProperty(writer, prop, options); + } + + if (isNull) + { + writer.WriteNullValue(); + } + else + { + writer.WriteEndObject(); + } + + return; + } + + // Handle Newtonsoft.Json.Linq.JObject by delegating to JsonConverterJObject + if (obj is Newtonsoft.Json.Linq.JObject jObject) + { + System.Text.Json.JsonSerializer.Serialize(writer, jObject, options); + return; + } + + // If STJ natively serializes this type as scalar, use STJ directly (no depth increment) + if (JsonSerializerHelper.IsStjNativeScalarType(obj)) + { + JsonSerializerHelper.SerializePrimitive(writer, obj, options); + return; + } + + // Check depth limit for complex types only (after scalar type check) + if (writer.CurrentDepth > _maxDepth) + { + WriteDepthExceeded(writer, pso, obj); + return; + } + + // For dictionaries, collections, and custom objects + if (obj is IDictionary dict) + { + SerializeDictionary(writer, pso, dict, options); + } + else if (obj is IEnumerable enumerable) + { + SerializeEnumerable(writer, enumerable, options); + } + else + { + // For custom objects, serialize as dictionary with properties + SerializeAsObject(writer, pso, options); + } + } + + private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj) + { + // Write warning once + 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); + } + + // Convert to string when depth exceeded + string stringValue = LanguagePrimitives.ConvertTo(pso.ImmediateBaseObjectIsEmpty ? pso : obj); + writer.WriteStringValue(stringValue); + } + + private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var item in enumerable) + { + WriteValue(writer, item, options); + } + + writer.WriteEndArray(); + } + + 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 (skip for base properties only mode) + if (!_basePropertiesOnly) + { + AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended); + } + + writer.WriteEndObject(); + } + + private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + // Handle null values (including AutomationNull) + if (LanguagePrimitives.IsNull(value) || value is DBNull) + { + writer.WriteNullValue(); + } + 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 + { + // Raw object: check if STJ natively handles this type + if (JsonSerializerHelper.IsStjNativeScalarType(value)) + { + // STJ handles this type natively as scalar + JsonSerializerHelper.SerializePrimitive(writer, value, options); + } + else + { + // Not a native scalar type - delegate to JsonConverterRawObject (Base properties only) + System.Text.Json.JsonSerializer.Serialize(writer, new RawObjectWrapper(value), typeof(RawObjectWrapper), options); + } + } + } + + private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options) + { + writer.WriteStartObject(); + 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, + bool filterToPropertyOnly = false) + { + var properties = new PSMemberInfoIntegratingCollection( + pso, + PSObject.GetPropertyCollection(memberTypes)); + + 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; + } + + WriteProperty(writer, prop, options); + } + } + + private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options) + { + try + { + var value = prop.Value; + writer.WritePropertyName(prop.Name); + + // 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.WriteStringValue(value.ToString()); + } + else + { + WriteValue(writer, value, options); + } + } + catch + { + // Skip properties that throw on access - write nothing for this property + } + } + } + + /// + /// 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)); + } + } + + /// + /// 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); + } + } + } + + /// + /// 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. + /// + 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); + } + } + + /// + /// Wrapper class for raw .NET objects to distinguish them from PSObjects at the type level. + /// Inherits from PSObject to avoid rewrapping when passed to JsonConverterPSObject. + /// + internal sealed class RawObjectWrapper : PSObject + { + public RawObjectWrapper(object value) : base(value) + { + } + } + + /// + /// 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 JsonConverterPSObject _psoConverter; + + public JsonConverterRawObject(PSCmdlet? cmdlet, int maxDepth) + { + _psoConverter = new JsonConverterPSObject(cmdlet, maxDepth, basePropertiesOnly: true); + } + + public override RawObjectWrapper? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, RawObjectWrapper wrapper, JsonSerializerOptions options) + { + // RawObjectWrapper inherits from PSObject, so no rewrapping needed + _psoConverter.Write(writer, wrapper, options); + } + } + + /// + /// 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). + /// Uses JsonTypeInfoKind to classify types. Results are cached per type for performance. + /// + 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; + } + + // 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))) + { + return true; + } + + if (obj is float f && (float.IsInfinity(f) || float.IsNaN(f))) + { + return true; + } + + return s_stjNativeScalarTypeCache.GetOrAdd(type, static t => + { + var typeInfo = JsonSerializerOptions.Default.GetTypeInfo(t); + return typeInfo.Kind == JsonTypeInfoKind.None; + }); + } + + public static void SerializePrimitive(Utf8JsonWriter writer, object obj, JsonSerializerOptions options) + { + // 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); + } + + 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; + } + } +} diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 7e17ec43137..c3098dee703 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 for ConvertTo-Json with V1-compatible behavior and support for non-string dictionary keys." + ), new ExperimentalFeature( name: PSProfileDSCResource, description: "DSC v3 resources for managing PowerShell profile." 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. 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..3d54ea083c9 --- /dev/null +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +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 + } + } + + 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 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' { + 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' + } + + # 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 + $jsonInputObject = ConvertTo-Json -InputObject $guid -Compress + $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 1f2abe05c68..b8fcadfadce 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,204 @@ Describe 'ConvertTo-Json' -tags "CI" { $actual = ConvertTo-Json -Compress -InputObject $obj $actual | Should -Be '{"Positive":18446744073709551615,"Negative":-18446744073709551615}' } + + It 'Should serialize special floating-point values as strings' { + [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' { + @() | ConvertTo-Json | Should -BeNull + } + + 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 Uri correctly' { + $uri = [uri]"https://example.com/path" + $json = $uri | ConvertTo-Json -Compress + $json | Should -BeExactly '"https://example.com/path"' + } + + 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 PSCustomObject 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 HTML tag characters by default' { + $json = @{ text = '<>&' } | ConvertTo-Json -Compress + $json | Should -BeExactly '{"text":"<>&"}' + } + + 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 'Depth over 100 should throw' { + { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw + } + + Context 'Nested raw object serialization' { + BeforeAll { + class TestClassWithFileInfo { + [System.IO.FileInfo]$File + } + + $script:testFilePath = Join-Path $PSHOME 'pwsh.dll' + if (-not (Test-Path $script:testFilePath)) { + $script:testFilePath = Join-Path $PSHOME 'System.Management.Automation.dll' + } + } + + It 'Typed property with raw FileInfo should serialize Base properties only' { + $obj = [TestClassWithFileInfo]::new() + $obj.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 + } + + 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 + } + } + + 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 + } } 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); + } + } +}