Last active
October 15, 2025 12:10
-
-
Save trackd/dabdabd3e592abbbd29a80c45cc5ab3f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| # example usage | |
| class foo { | |
| [secret] $thing | |
| [string] $name | |
| } | |
| $test = [foo]@{ | |
| thing = [Secret]::new('supersecretthing') | |
| name = 'bob' | |
| } | |
| $test | ConvertTo-Json | |
| $test | ConvertTo-JsonSecret | |
| # solves comparison | |
| [Secret]::new('supersecretthing') -eq [Secret]::new('supersecretthing') | |
| # true | |
| vs | |
| (ConvertTo-SecureString 'Testing' -AsPlainText) -eq (ConvertTo-SecureString 'Testing' -AsPlainText) | |
| # false | |
| note: FallbackJsonConverter is sort of a best effort to handle arbitrary objects without failing serialization. | |
| #> | |
| using namespace Newtonsoft.Json | |
| using namespace Newtonsoft.Json.Converters | |
| using namespace Newtonsoft.Json.Serialization | |
| using namespace System.Collections | |
| using namespace System.Collections.Generic | |
| using namespace System.Reflection | |
| using namespace System.Management.Automation | |
| class Secret { | |
| [securestring] $value | |
| Secret([string] $plainText) { | |
| $this.value = ConvertTo-SecureString -String $plainText -AsPlainText | |
| } | |
| [string] ToPlainText() { | |
| return ConvertFrom-SecureString -SecureString $this.value -AsPlainText | |
| } | |
| [bool] Equals([object] $other) { | |
| if ($null -eq $other -or -not ($other -is [Secret])) { | |
| return $false | |
| } | |
| return $this.ToPlainText() -eq $other.ToPlainText() | |
| } | |
| } | |
| class SecretJsonConverter : JsonConverter { | |
| SecretJsonConverter() {} | |
| [bool] CanConvert([Type] $objectType) { | |
| return $objectType -eq [Secret] | |
| } | |
| [void] WriteJson([JsonWriter] $writer, [object] $value, [JsonSerializer] $serializer) { | |
| if ($null -eq $value) { | |
| $writer.WriteNull() | |
| return | |
| } | |
| $writer.WriteValue($value.ToPlainText()) | |
| } | |
| [object] ReadJson([JsonReader] $reader, [Type] $objectType, [object] $existingValue, [JsonSerializer] $serializer) { | |
| if ($reader.TokenType -eq [JsonToken]::Null) { return $null } | |
| $plain = $reader.Value.ToString() | |
| return [Secret]::new($plain) | |
| } | |
| } | |
| class FallbackJsonConverter : JsonConverter { | |
| FallbackJsonConverter() {} | |
| [bool] CanConvert([Type] $objectType) { | |
| if ($null -eq $objectType) { return $false } | |
| if ($objectType -eq [string]) { return $false } | |
| if ($objectType.IsPrimitive) { return $false } | |
| if ($objectType.IsEnum) { return $false } | |
| if ($objectType.IsValueType) { return $false } | |
| if ([IEnumerable].IsAssignableFrom($objectType)) { return $false } | |
| if ($objectType -eq [Linq.JToken] -or $objectType.IsSubclassOf([Linq.JToken])) { return $false } | |
| return $true | |
| } | |
| [string] SafeToString([object] $obj) { | |
| $toStringResult = ${obj}?.ToString() | |
| if ([string]::IsNullOrEmpty($toStringResult)) { | |
| $toStringResult = [LanguagePrimitives]::ConvertTo($obj, [string], [System.Globalization.CultureInfo]::InvariantCulture) | |
| } | |
| return $toStringResult | |
| } | |
| [void] WriteStringOrEmpty([JsonWriter] $writer, [string] $s) { | |
| if (-not [string]::IsNullOrEmpty($s)) { | |
| $writer.WriteValue($s) | |
| } | |
| else { | |
| $writer.WriteValue('') | |
| } | |
| } | |
| [bool] TryWriteString([JsonWriter] $writer, [object] $obj) { | |
| $s = $this.SafeToString($obj) | |
| try { | |
| if (-not [string]::IsNullOrEmpty($s)) { | |
| $writer.WriteValue($s) | |
| return $true | |
| } | |
| } | |
| catch { | |
| return $false | |
| } | |
| return $false | |
| } | |
| [void] WriteValueWithFallback([JsonWriter] $writer, [object] $val, [object] $parent, [JsonSerializer] $serializer) { | |
| # Null short-circuit: write JSON null for null values immediately | |
| if ($null -eq $val) { | |
| $writer.WriteNull() | |
| return | |
| } | |
| try { | |
| $serializer.Serialize($writer, $val) | |
| return | |
| } | |
| catch { | |
| # Compute string fallbacks once to avoid repeated ToString() calls. | |
| $valStr = $this.SafeToString($val) | |
| if ($null -ne $valStr) { | |
| $this.WriteStringOrEmpty($writer, $valStr) | |
| return | |
| } | |
| $parentStr = $this.SafeToString($parent) | |
| if ($null -ne $parentStr) { | |
| $this.WriteStringOrEmpty($writer, $parentStr) | |
| return | |
| } | |
| $this.WriteStringOrEmpty($writer, $null) | |
| } | |
| } | |
| [void] WritePropertySafely([JsonWriter] $writer, [PropertyInfo] $prop, [object] $obj, [JsonSerializer] $serializer) { | |
| $writer.WritePropertyName($prop.Name) | |
| try { | |
| $val = $prop.GetValue($obj) | |
| $this.WriteValueWithFallback($writer, $val, $obj, $serializer) | |
| } | |
| catch { | |
| $this.WriteStringOrEmpty($writer, $this.SafeToString($obj)) | |
| } | |
| } | |
| [void] WriteFieldSafely([JsonWriter] $writer, [FieldInfo] $field, [object] $obj, [JsonSerializer] $serializer) { | |
| $writer.WritePropertyName($field.Name) | |
| try { | |
| $val = $field.GetValue($obj) | |
| $this.WriteValueWithFallback($writer, $val, $obj, $serializer) | |
| } | |
| catch { | |
| $this.WriteStringOrEmpty($writer, $this.SafeToString($obj)) | |
| } | |
| } | |
| [void] WriteJson([JsonWriter] $writer, [object] $value, [JsonSerializer] $serializer) { | |
| if ($null -eq $value) { | |
| $writer.WriteNull() | |
| return | |
| } | |
| $runtimeType = $value.GetType() | |
| # If there are other converters registered, check them first. If the list | |
| # essentially contains only this fallback, skip the scan to save time. | |
| if ($serializer.Converters.Count -gt 1) { | |
| foreach ($conv in $serializer.Converters) { | |
| if ($conv -ne $this -and $conv.CanConvert($runtimeType)) { | |
| $conv.WriteJson($writer, $value, $serializer) | |
| return | |
| } | |
| } | |
| } | |
| $convList = $serializer.Converters | |
| $myIndex = $convList.IndexOf($this) | |
| $removed = $false | |
| if ($myIndex -ge 0) { | |
| $convList.RemoveAt($myIndex) | |
| $removed = $true | |
| } | |
| try { | |
| try { | |
| $serializer.Serialize($writer, $value) | |
| return | |
| } | |
| catch { | |
| $writer.WriteStartObject() | |
| foreach ($p in $runtimeType.GetProperties([BindingFlags]::Public -bor [BindingFlags]::Instance)) { | |
| if ($p.GetIndexParameters().Length -gt 0) { | |
| continue | |
| } | |
| $this.WritePropertySafely($writer, $p, $value, $serializer) | |
| } | |
| foreach ($f in $runtimeType.GetFields([BindingFlags]::Public -bor [BindingFlags]::Instance)) { | |
| $this.WriteFieldSafely($writer, $f, $value, $serializer) | |
| } | |
| $writer.WriteEndObject() | |
| return | |
| } | |
| } | |
| finally { | |
| if ($removed) { | |
| if ($myIndex -le $convList.Count) { | |
| $convList.Insert($myIndex, $this) | |
| } | |
| else { | |
| $convList.Add($this) | |
| } | |
| } | |
| } | |
| } | |
| [object] ReadJson([JsonReader] $reader, [Type] $objectType, [object] $existingValue, [JsonSerializer] $serializer) { | |
| throw [NotSupportedException]::new('Deserializing arbitrary CLR types is unsupported by the fallback converter') | |
| } | |
| } | |
| function ConvertTo-JsonSecret { | |
| [cmdletbinding()] | |
| param( | |
| [Parameter(ValueFromPipeline, Position = 0, Mandatory)] | |
| [AllowNull()] | |
| [object] $InputObject, | |
| [Switch] $Compress, | |
| [switch] $EnumsAsStrings, | |
| [switch] $AsArray, | |
| [StringEscapeHandling] $EscapeHandling, | |
| [cultureinfo] $Culture = [cultureinfo]::InvariantCulture, | |
| [ValidateRange(0, 100)] | |
| [int] $Depth = 100 | |
| ) | |
| begin { | |
| $settings = [JsonSerializerSettings]@{ | |
| TypeNameHandling = [TypeNameHandling]::None | |
| MetadataPropertyHandling = [MetadataPropertyHandling]::Ignore | |
| ReferenceLoopHandling = [ReferenceLoopHandling]::Ignore | |
| DateFormatHandling = [DateFormatHandling]::IsoDateFormat | |
| DateTimeZoneHandling = [DateTimeZoneHandling]::Utc | |
| DateParseHandling = [DateParseHandling]::DateTimeOffset | |
| DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.fffK" | |
| } | |
| $settings.Converters.Add([SecretJsonConverter]::new()) | |
| # this is a catch-all to avoid serialization errors | |
| $settings.Converters.Add([FallbackJsonConverter]::new()) | |
| if ($EnumsAsStrings) { | |
| $settings.Converters.Add([StringEnumConverter]::new()) | |
| } | |
| if (-not $Compress.IsPresent) { | |
| $settings.Formatting = [Formatting]::Indented | |
| } | |
| if ($Depth) { | |
| $settings.MaxDepth = $Depth | |
| } | |
| if ($EscapeHandling) { | |
| $settings.StringEscapeHandling = $EscapeHandling | |
| } | |
| if ($Culture) { | |
| $settings.Culture = $Culture | |
| } | |
| $collection = [List[object]]::new() | |
| } | |
| process { | |
| $collection.Add($InputObject) | |
| } | |
| end { | |
| if ($collection.Count -gt 0) { | |
| $collection = if ($collection.Count -gt 1 -or $AsArray.IsPresent) { $collection.ToArray() } else { $collection[0] } | |
| [Newtonsoft.Json.JsonConvert]::SerializeObject($collection, $settings) | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment