Skip to content

Instantly share code, notes, and snippets.

@trackd
Last active October 15, 2025 12:10
Show Gist options
  • Select an option

  • Save trackd/dabdabd3e592abbbd29a80c45cc5ab3f to your computer and use it in GitHub Desktop.

Select an option

Save trackd/dabdabd3e592abbbd29a80c45cc5ab3f to your computer and use it in GitHub Desktop.
<#
# 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