<# Copyright (c) 2026, R. W. Rodolico All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #> <# .SYNOPSIS Edits a JSON or INI configuration file without syntax mistakes (Windows PowerShell 5.1). .DESCRIPTION dsEdit for Windows (bug 216). Technicians browse the file as a tree and change values, add or delete entries, and save; the file is only ever written from a valid structure, so a misplaced brace or comma cannot happen. Formats: JSON (.json) and INI (.ini, .conf, .info, and server.info with the colon delimiter). YAML is not supported on Windows. It is a separate implementation of the Unix dsEdit's idea, not a port: command line parity is not a goal. Without -Set, -Add, -Remove or -Show it opens the interactive navigator (Read-Host, so it works in the ISE and in a plain console). Type ? there for the commands. Saving writes UTF-8 without a BOM to a temporary file beside the original, re-reads it to check it, then replaces the original with [IO.File]::Replace, which keeps the original's ACL and leaves the previous contents in .bak (with the same locked ACL, because the backup is the original file itself). A file under C:\ProgramData\camp that the current user cannot open makes dsEdit start an elevated copy of itself (UAC prompt) and exit, so a plain desktop shortcut is enough. The unelevated copy exits 0 as soon as the elevated one starts, so a script cannot learn whether the edit succeeded (run it from an elevated prompt for that). A file on a mapped drive (Z:\...) is usually not visible to the elevated copy; use the UNC path. Desktop shortcut target (the file is the first positional parameter; -Pause keeps the window open until Enter, and applies to the elevated copy): powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\camp\sysinfo_client\dsEdit.ps1" -Pause "C:\ProgramData\camp\server.info" Can be dot-sourced (by t\windows\run_tests.ps1) without running. .PARAMETER Path File to edit (alias -File). Relative paths are taken from the current directory; %VARIABLES% are expanded. Without it: -ClientConfig, or the client config and server.info that exist. .PARAMETER ClientConfig Edit the installed client config (C:\ProgramData\camp\sysinfo_client\sysinfo_client.json). Ignored when -Path is given. configure.ps1 passes it. .PARAMETER Format json or ini. Default: from the file extension. .PARAMETER Delimiter INI key/value delimiter. Default '=' (':' for server.info and *.info). .PARAMETER Set key=value pairs; dotted path (update.url=http://x). An existing value keeps its type; a new one becomes a number or true/false when it looks like one, otherwise a string. .PARAMETER Add key=value pairs; appends the value to the list at key (created if absent), unless already there. .PARAMETER Remove key deletes the entry; key=value removes that value from the list at key. .PARAMETER Show Print the file as it would be written (after any -Set/-Add/-Remove) instead of opening the navigator. .PARAMETER Force Save from the command line even when the known-key check warns. .PARAMETER Pause Wait for Enter before the window closes (desktop shortcut). .PARAMETER NoElevate Do not start an elevated copy when the file cannot be opened. #> [CmdletBinding()] param( [Parameter(Position = 0)] [Alias('File')] [string]$Path = '', [switch]$ClientConfig, [string]$Format = '', [string]$Delimiter = '', [string[]]$Set = @(), [string[]]$Add = @(), [string[]]$Remove = @(), [switch]$Show, [switch]$Force, [switch]$Pause, [switch]$NoElevate ) # Version 1.0.0 20261027 Claude # Initial version (bug 216): JSON and INI, interactive navigator, -Set/-Add/-Remove/-Show, # atomic replace with backup, elevation for shortcuts. Review fixes (still unreleased): elevation # decided by opening the file, owner reset to Administrators on save, PSBase for key names such as # Count/Keys, non-finite numbers refused, ISE output, safe relaunch quoting, dates/big integers kept # as written, strict UTF-8 with ANSI fallback, failed-replace recovery, INI key validation $script:DsEditVersion = '1.0.0' $script:Utf8NoBom = New-Object System.Text.UTF8Encoding $false $script:Utf8Strict = New-Object System.Text.UTF8Encoding($false, $true) $script:InIse = ($Host.Name -eq 'Windows PowerShell ISE Host') $script:Invariant = [System.Globalization.CultureInfo]::InvariantCulture $script:OnWindows = ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) $script:PauseAtEnd = [bool]$Pause $script:Ed = $null # session state: Path, Format, Delimiter, Root, Header, Newline, Text, Lost ####################################################### # Data model: ordered maps, ArrayLists, strings, numbers, booleans ####################################################### function New-EditMap { return , (New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)) } function Get-EditKind { param($Value) if ($null -eq $Value) { return 'null' } if ($Value -is [string]) { return 'string' } if ($Value -is [System.Collections.IDictionary]) { return 'map' } if ($Value -is [bool]) { return 'bool' } if ($Value -is [System.Collections.IEnumerable]) { return 'list' } if ($Value -is [System.Numerics.BigInteger] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal] -or $Value -is [single] -or $Value -is [int16] -or $Value -is [byte] -or $Value -is [uint32] -or $Value -is [uint64]) { return 'number' } return 'string' } # ConvertFrom-Json output (PSCustomObject, which 5.1 cannot turn into hashtables itself) to maps and lists function ConvertTo-EditData { param([AllowNull()]$Value) if ($null -eq $Value) { return $null } if ($Value -is [string]) { return $Value } if ($Value -is [datetime]) { throw 'the file holds a date value that this PowerShell would rewrite on save (use PowerShell 7.5 or later, or edit the file by hand)' } if ($Value.GetType().FullName -eq 'System.Management.Automation.PSCustomObject') { $map = New-EditMap foreach ($property in $Value.PSObject.Properties) { $map[$property.Name] = ConvertTo-EditData $property.Value } return , $map } if ($Value -is [System.Collections.IDictionary]) { $map = New-EditMap foreach ($key in $Value.PSBase.Keys) { $map[[string]$key] = ConvertTo-EditData $Value[$key] } return , $map } if ($Value -is [System.Collections.IEnumerable]) { $list = New-Object System.Collections.ArrayList foreach ($item in $Value) { [void]$list.Add((ConvertTo-EditData $item)) } return , $list } return $Value } ####################################################### # # ConvertTo-EditJsonString( $Text ) # ConvertTo-EditJson( $Value, $Level ) # # Own serializer: ConvertTo-Json in 5.1 stringifies anything deeper than -Depth without an # error, indents oddly and escapes < > ' as \u003c. This one is deterministic, 3-space # indented like the client template, and has no depth limit. Lines end in LF. # ####################################################### function ConvertTo-EditJsonString { param([string]$Text) $sb = New-Object System.Text.StringBuilder [void]$sb.Append('"') foreach ($c in $Text.ToCharArray()) { switch ($c) { '"' { [void]$sb.Append('\"') } '\' { [void]$sb.Append('\\') } "`n" { [void]$sb.Append('\n') } "`r" { [void]$sb.Append('\r') } "`t" { [void]$sb.Append('\t') } "`b" { [void]$sb.Append('\b') } "`f" { [void]$sb.Append('\f') } default { if ([int]$c -lt 32) { [void]$sb.Append('\u' + ([int]$c).ToString('x4')) } else { [void]$sb.Append($c) } } } } [void]$sb.Append('"') return $sb.ToString() } function ConvertTo-EditJson { param([AllowNull()]$Value, [int]$Level = 0) $kind = Get-EditKind $Value if ($kind -eq 'null') { return 'null' } if ($kind -eq 'bool') { if ($Value) { return 'true' } else { return 'false' } } if ($kind -eq 'number') { if ($Value -is [double] -or $Value -is [single]) { return ([double]$Value).ToString('R', $script:Invariant) } return $Value.ToString([string]$null, $script:Invariant) } if ($kind -eq 'string') { return (ConvertTo-EditJsonString ([string]$Value)) } $pad = ' ' * (3 * ($Level + 1)) $padEnd = ' ' * (3 * $Level) $parts = New-Object System.Collections.ArrayList if ($kind -eq 'map') { if ($Value.PSBase.Count -eq 0) { return '{}' } foreach ($key in @($Value.PSBase.Keys)) { [void]$parts.Add($pad + (ConvertTo-EditJsonString ([string]$key)) + ': ' + (ConvertTo-EditJson $Value[$key] ($Level + 1))) } return "{`n" + ($parts.ToArray() -join ",`n") + "`n" + $padEnd + '}' } $items = @($Value) if ($items.Count -eq 0) { return '[]' } foreach ($item in $items) { [void]$parts.Add($pad + (ConvertTo-EditJson $item ($Level + 1))) } return "[`n" + ($parts.ToArray() -join ",`n") + "`n" + $padEnd + ']' } # JSON text to a map; the root must be an object. Throws with the parser's message. function Read-EditJson { param([string]$Text) if ($Text.Trim() -eq '') { return , (New-EditMap) } try { if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) { $parsed = ConvertFrom-Json -InputObject $Text -DateKind String } else { $parsed = ConvertFrom-Json -InputObject $Text } } catch { throw "not valid JSON: $($_.Exception.Message)" } $data = ConvertTo-EditData $parsed if ((Get-EditKind $data) -ne 'map') { throw 'the top level of the JSON must be an object ({ ... })' } return , $data } ####################################################### # # Read-EditIni( $Text, $Delimiter ) / ConvertTo-EditIni( $Root, $Header, $Delimiter ) # # INI: top-level key=value lines and [section] blocks of key=value lines; values are strings. # Comment lines before the first entry are kept (the Header); other comments, blank lines and # lines without a delimiter are not kept (Lost counts the non-blank ones). # ####################################################### function Read-EditIni { param([string]$Text, [string]$Delimiter) $root = New-EditMap $header = New-Object System.Collections.ArrayList $current = $root $started = $false $lost = 0 foreach ($line in ($Text -split "`r?`n")) { $t = $line.Trim() if ($t -eq '' -or $t.StartsWith('#') -or $t.StartsWith(';')) { if (-not $started) { [void]$header.Add($line) } elseif ($t -ne '') { $lost++ } continue } $started = $true if ($t -match '^\[(.+)\]$') { $name = $Matches[1].Trim() if ($root.Contains($name)) { if ((Get-EditKind $root[$name]) -ne 'map') { throw "section [$name] has the same name as a key" } } else { $root[$name] = New-EditMap } $current = $root[$name] continue } $at = $t.IndexOf($Delimiter) if ($at -lt 1) { $lost++; continue } $current[$t.Substring(0, $at).Trim()] = $t.Substring($at + $Delimiter.Length).Trim() } while ($header.Count -gt 0 -and $header[$header.Count - 1].Trim() -eq '') { $header.RemoveAt($header.Count - 1) } return @{ 'Root' = $root; 'Header' = $header; 'Lost' = $lost } } # '' when the name survives an INI round trip, else the reason function Get-EditIniKeyProblem { param([string]$Name, [string]$Delimiter, [bool]$Section) if ($Name -eq '' -or $Name -ne $Name.Trim()) { return "'$Name' is empty or has leading/trailing spaces" } if ($Name -match '[\r\n]') { return "'$Name' contains a line break" } if ($Section) { return '' } if ($Delimiter -ne '' -and $Name.Contains($Delimiter)) { return "'$Name' contains the delimiter '$Delimiter'" } if ($Name -match '^[#;\[]') { return "'$Name' starts with a character that makes it a comment or section" } return '' } function ConvertTo-EditIni { param($Root, $Header, [string]$Delimiter) $lines = New-Object System.Collections.ArrayList foreach ($h in @($Header)) { [void]$lines.Add([string]$h) } if ($lines.Count -gt 0) { [void]$lines.Add('') } $glue = '=' if ($Delimiter -eq ':') { $glue = ': ' } elseif ($Delimiter -ne '=') { $glue = $Delimiter } foreach ($key in @($Root.PSBase.Keys)) { $isSection = ((Get-EditKind $Root[$key]) -eq 'map') $problem = Get-EditIniKeyProblem ([string]$key) $Delimiter $isSection if ($problem -ne '') { throw "INI name $problem" } if ($isSection) { foreach ($inner in @($Root[$key].PSBase.Keys)) { $problem = Get-EditIniKeyProblem ([string]$inner) $Delimiter $false if ($problem -ne '') { throw "INI name $problem" } if (([string]$Root[$key][$inner]) -match '[\r\n]') { throw "INI value of '$inner' contains a line break" } } } elseif (([string]$Root[$key]) -match '[\r\n]') { throw "INI value of '$key' contains a line break" } } foreach ($key in @($Root.PSBase.Keys)) { if ((Get-EditKind $Root[$key]) -ne 'map') { [void]$lines.Add(([string]$key) + $glue + ([string]$Root[$key])) } } foreach ($key in @($Root.PSBase.Keys)) { if ((Get-EditKind $Root[$key]) -eq 'map') { if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -ne '') { [void]$lines.Add('') } [void]$lines.Add("[$key]") foreach ($inner in @($Root[$key].PSBase.Keys)) { [void]$lines.Add(([string]$inner) + $glue + ([string]$Root[$key][$inner])) } } } return (($lines.ToArray() -join "`n") + "`n") } ####################################################### # Value conversion ####################################################### # text typed for a value of the given kind -> @{Ok; Value; Error} function Convert-EditInput { param([string]$Text, [string]$Kind) if ($Kind -eq 'number') { $long = [long]0 if ([long]::TryParse($Text.Trim(), [System.Globalization.NumberStyles]::AllowLeadingSign, $script:Invariant, [ref]$long)) { if ($long -ge [int]::MinValue -and $long -le [int]::MaxValue) { return @{ 'Ok' = $true; 'Value' = [int]$long } } return @{ 'Ok' = $true; 'Value' = $long } } $double = [double]0 if ([double]::TryParse($Text.Trim(), [System.Globalization.NumberStyles]::Float, $script:Invariant, [ref]$double)) { if ([double]::IsNaN($double) -or [double]::IsInfinity($double)) { return @{ 'Ok' = $false; 'Error' = "'$Text' is not a finite number" } } return @{ 'Ok' = $true; 'Value' = $double } } return @{ 'Ok' = $false; 'Error' = "'$Text' is not a number" } } if ($Kind -eq 'bool') { if ($Text.Trim() -eq 'true') { return @{ 'Ok' = $true; 'Value' = $true } } if ($Text.Trim() -eq 'false') { return @{ 'Ok' = $true; 'Value' = $false } } return @{ 'Ok' = $false; 'Error' = "'$Text' is not true or false" } } return @{ 'Ok' = $true; 'Value' = $Text } } # new value from a command line: number or boolean only when it looks like one (and the format allows) function Convert-EditGuess { param([string]$Text) if ($null -ne $script:Ed -and $script:Ed['Format'] -eq 'ini') { return $Text } if ($Text -eq 'true') { return $true } if ($Text -eq 'false') { return $false } if ($Text -match '^-?(0|[1-9][0-9]*)$') { return (Convert-EditInput $Text 'number')['Value'] } if ($Text -match '^-?(0|[1-9][0-9]*)\.[0-9]+$') { return (Convert-EditInput $Text 'number')['Value'] } return $Text } ####################################################### # Command-line operations (-Set, -Add, -Remove). Each returns a message; a problem throws. ####################################################### function Get-EditSegments { param([string]$Spec) $segments = @($Spec.Split('.') | ForEach-Object { $_.Trim() }) foreach ($segment in $segments) { if ($segment -eq '') { throw "bad key '$Spec'" } } if ($script:Ed['Format'] -eq 'ini' -and $segments.Count -gt 2) { throw "INI allows [section] and key only: '$Spec'" } return , $segments } # the map holding the last segment; creates missing maps on the way when $Create function Get-EditParent { param($Root, [string[]]$Segments, [bool]$Create) $cursor = $Root for ($i = 0; $i -lt $Segments.Count - 1; $i++) { $name = $Segments[$i] if ($cursor.Contains($name)) { if ((Get-EditKind $cursor[$name]) -ne 'map') { throw "'$name' is not a map" } } elseif ($Create) { $cursor[$name] = New-EditMap } else { return $null } $cursor = $cursor[$name] } return , $cursor } function Split-EditSpec { param([string]$Spec, [string]$Option) $eq = $Spec.IndexOf('=') if ($eq -lt 1) { throw "$Option needs key=value, got '$Spec'" } return @($Spec.Substring(0, $eq).Trim(), $Spec.Substring($eq + 1)) } function Invoke-EditSet { param($Root, [string]$Spec) $pair = Split-EditSpec $Spec '-Set' $segments = Get-EditSegments $pair[0] $leaf = $segments[$segments.Count - 1] $parent = Get-EditParent $Root $segments $true $text = $pair[1] if ($parent.Contains($leaf)) { $kind = Get-EditKind $parent[$leaf] if ($kind -eq 'map' -or $kind -eq 'list') { throw "'$($pair[0])' is a $kind; set one of its entries, or use -Add for a list" } if ($kind -eq 'null') { $kind = 'string' } $converted = Convert-EditInput $text $kind if (-not $converted['Ok']) { throw "$($pair[0]): $($converted['Error'])" } $parent[$leaf] = $converted['Value'] } else { $parent[$leaf] = Convert-EditGuess $text } return "set $($pair[0])" } function Invoke-EditAdd { param($Root, [string]$Spec) if ($script:Ed['Format'] -eq 'ini') { throw 'INI has no lists; use -Set' } $pair = Split-EditSpec $Spec '-Add' $segments = Get-EditSegments $pair[0] $leaf = $segments[$segments.Count - 1] $parent = Get-EditParent $Root $segments $true if ($parent.Contains($leaf)) { if ((Get-EditKind $parent[$leaf]) -ne 'list') { throw "'$($pair[0])' is not a list" } } else { $parent[$leaf] = New-Object System.Collections.ArrayList } $list = $parent[$leaf] $value = $pair[1] if ($list.Count -gt 0) { $first = Get-EditKind $list[0] if ($first -eq 'number' -or $first -eq 'bool') { $converted = Convert-EditInput $value $first if (-not $converted['Ok']) { throw "$($pair[0]): $($converted['Error'])" } $value = $converted['Value'] } } foreach ($item in $list) { if ([string]$item -ceq [string]$value) { return "$($pair[0]) already has '$($pair[1])'" } } [void]$list.Add($value) return "added '$($pair[1])' to $($pair[0])" } function Invoke-EditRemove { param($Root, [string]$Spec) $eq = $Spec.IndexOf('=') $path = $Spec if ($eq -ge 1) { $path = $Spec.Substring(0, $eq).Trim() } $segments = Get-EditSegments $path $leaf = $segments[$segments.Count - 1] $parent = Get-EditParent $Root $segments $false if ($null -eq $parent -or -not $parent.Contains($leaf)) { return "$path is not present" } if ($eq -lt 1) { $parent.Remove($leaf) return "removed $path" } if ((Get-EditKind $parent[$leaf]) -ne 'list') { throw "'$path' is not a list" } $value = $Spec.Substring($eq + 1) $list = $parent[$leaf] $removed = 0 for ($i = $list.Count - 1; $i -ge 0; $i--) { if ([string]$list[$i] -ceq $value) { $list.RemoveAt($i); $removed++ } } if ($removed -eq 0) { return "$path has no '$value'" } return "removed '$value' from $path" } ####################################################### # # Get-EditWarnings( $Root, $Leaf ) # # Known-key check for the client config (file named sysinfo_client*.json, or one with a # clientName). Types only; returns a list of messages, empty when all is well. # ####################################################### function Get-EditWarnings { param($Root, [string]$Leaf) $found = New-Object System.Collections.ArrayList if ($script:Ed['Format'] -ne 'json') { return , $found } if (-not ($Leaf -like 'sysinfo_client*.json') -and -not $Root.Contains('clientName')) { return , $found } $expect = @{ 'clientName' = 'string'; 'location' = 'string'; 'hostname' = 'string'; 'serialNumber' = 'string'; 'UUID' = 'string' 'os_type' = 'string'; 'tags' = 'list'; 'sensorTimeout' = 'number'; 'TESTING' = 'number' 'logging' = 'map'; 'transports' = 'map'; 'update' = 'map' } foreach ($key in $expect.Keys) { if (-not $Root.Contains($key)) { continue } $kind = Get-EditKind $Root[$key] if ($kind -ne $expect[$key]) { [void]$found.Add("$key should be a $($expect[$key]) but is a $kind") } } if ($Root.Contains('tags') -and (Get-EditKind $Root['tags']) -eq 'list') { foreach ($tag in $Root['tags']) { if ((Get-EditKind $tag) -ne 'string') { [void]$found.Add('tags should hold only strings'); break } } } if ($Root.Contains('transports') -and (Get-EditKind $Root['transports']) -eq 'map') { foreach ($name in @($Root['transports'].PSBase.Keys)) { if ((Get-EditKind $Root['transports'][$name]) -ne 'map') { [void]$found.Add("transports.$name should be a map") } } } return , $found } ####################################################### # Files ####################################################### function Resolve-EditPath { param([string]$Name) $name = [System.Environment]::ExpandEnvironmentVariables($Name.Trim().Trim('"')) if ([IO.Path]::IsPathRooted($name)) { return [IO.Path]::GetFullPath($name) } return [IO.Path]::GetFullPath([IO.Path]::Combine($PWD.ProviderPath, $name)) } function Get-EditClientConfigPath { if ($env:ProgramData) { return [IO.Path]::Combine($env:ProgramData, 'camp', 'sysinfo_client', 'sysinfo_client.json') } return '' } # @{Format; Delimiter} from -Format/-Delimiter or the file name; throws when it cannot tell function Get-EditFormat { param([string]$File, [string]$FormatName, [string]$DelimiterName) $leaf = [IO.Path]::GetFileName($File).ToLowerInvariant() $ext = [IO.Path]::GetExtension($File).ToLowerInvariant() $format = $FormatName.Trim().ToLowerInvariant() if ($format -eq '') { if ($ext -eq '.json') { $format = 'json' } elseif ($ext -eq '.ini' -or $ext -eq '.conf' -or $ext -eq '.info') { $format = 'ini' } elseif ($ext -eq '.yaml' -or $ext -eq '.yml') { throw 'YAML is not supported on Windows (JSON and INI only)' } else { throw "cannot tell the format of '$leaf'; use -Format json or -Format ini" } } if ($format -eq 'yaml' -or $format -eq 'yml') { throw 'YAML is not supported on Windows (JSON and INI only)' } if ($format -ne 'json' -and $format -ne 'ini') { throw "unknown format '$FormatName' (json or ini)" } $delimiter = $DelimiterName if ($format -eq 'ini' -and $delimiter -eq '') { if ($ext -eq '.info') { $delimiter = ':' } else { $delimiter = '=' } } return @{ 'Format' = $format; 'Delimiter' = $delimiter } } # 'ok', 'denied', 'missing' or an error message. Decided by opening the file: an unelevated # Test-Path says false for a file in a folder the user cannot read, which is not "missing". # A missing file needs a writable folder, probed with a throwaway file, else 'denied'. function Test-EditAccess { param([string]$File, [bool]$Write) $access = [IO.FileAccess]::Read if ($Write) { $access = [IO.FileAccess]::ReadWrite } try { $stream = [IO.File]::Open($File, [IO.FileMode]::Open, $access, [IO.FileShare]::ReadWrite) $stream.Close() return 'ok' } catch [System.UnauthorizedAccessException] { return 'denied' } catch [System.IO.DirectoryNotFoundException] { return "the folder for $File does not exist" } catch [System.IO.FileNotFoundException] { if (-not $Write) { return 'missing' } $probe = $File + '.probe' try { $stream = [IO.File]::Open($probe, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $stream.Close() [IO.File]::Delete($probe) return 'missing' } catch [System.UnauthorizedAccessException] { return 'denied' } catch { return $_.Exception.Message } } catch { return $_.Exception.Message } } function Test-EditElevated { if (-not $script:OnWindows) { return $true } $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() if ($identity.User.Value -eq 'S-1-5-18') { return $true } $principal = New-Object System.Security.Principal.WindowsPrincipal $identity return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } # argument string for powershell.exe: an -EncodedCommand (no quoting problems with spaces, quotes, lists) function Get-EditRelaunchArguments { param([string]$Script, [string]$File) $q = { param([string]$s) "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($s) + "'" } $command = '& ' + (& $q $Script) + ' -NoElevate -Pause -Path ' + (& $q $File) if ($Format -ne '') { $command += ' -Format ' + (& $q $Format) } if ($Delimiter -ne '') { $command += ' -Delimiter ' + (& $q $Delimiter) } foreach ($pair in @(@('Set', $Set), @('Add', $Add), @('Remove', $Remove))) { $values = @($pair[1]) if ($values.Count -gt 0) { $command += ' -' + $pair[0] + ' @(' + ((@($values | ForEach-Object { & $q $_ })) -join ',') + ')' } } if ($Show) { $command += ' -Show' } if ($Force) { $command += ' -Force' } $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) return '-NoProfile -ExecutionPolicy Bypass -EncodedCommand ' + $encoded } ####################################################### # # Write-EditFile( $File, $Text, $Newline ) # # Temp file beside the original (inherits the directory's locked ACL), read back and compared, # then [IO.File]::Replace(temp, original, original.bak): atomic, keeps the original's ACL, and # the backup is the original file itself so it keeps that ACL too. # ####################################################### # [IO.File]::Replace keeps the original's ACL but not its owner, and the client rejects a config # whose owner is not trusted, so the new file is owned by Administrators before it goes live. function Set-EditOwner { param([string]$File) if (-not $script:OnWindows) { return } $output = & icacls.exe $File /setowner '*S-1-5-32-544' 2>&1 if ($LASTEXITCODE -ne 0) { throw "could not set the owner of ${File}: $output" } } function Write-EditFile { param([string]$File, [string]$Text, [string]$Newline) $body = $Text.Replace("`r`n", "`n") if ($Newline -eq "`r`n") { $body = $body.Replace("`n", "`r`n") } $temp = $File + '.new' $existed = [IO.File]::Exists($File) $verified = $false try { [IO.File]::WriteAllText($temp, $body, $script:Utf8NoBom) Set-EditOwner $temp $back = [IO.File]::ReadAllText($temp, $script:Utf8NoBom) if ($back -cne $body) { throw 'the file read back differs from what was written; the original is unchanged' } $verified = $true if ($existed) { [IO.File]::Replace($temp, $File, ($File + '.bak'), $true) } else { [IO.File]::Move($temp, $File) } } catch [System.UnauthorizedAccessException] { throw "access denied writing $File; run elevated (Run as administrator)" } finally { # a Replace that failed after renaming the original leaves no file: put the data back if ($existed -and -not [IO.File]::Exists($File)) { try { if ($verified -and [IO.File]::Exists($temp)) { [IO.File]::Move($temp, $File) } elseif ([IO.File]::Exists($File + '.bak')) { [IO.File]::Move(($File + '.bak'), $File) } } catch { } } if ([IO.File]::Exists($temp)) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } ####################################################### # # Get-EditText() # # The current structure as file text (LF); for JSON it is parsed again and must serialize to # the same text, which also guards against any depth or type surprise. # ####################################################### function Get-EditText { if ($script:Ed['Format'] -eq 'json') { $text = (ConvertTo-EditJson $script:Ed['Root']) + "`n" $again = (ConvertTo-EditJson (Read-EditJson $text)) + "`n" if ($again -cne $text) { throw 'internal check failed: the JSON does not survive a round trip; nothing written' } return $text } return (ConvertTo-EditIni $script:Ed['Root'] $script:Ed['Header'] $script:Ed['Delimiter']) } # Loads $File into $script:Ed. A missing file starts empty. function Open-EditFile { param([string]$File, [hashtable]$Kind) $script:Ed = @{ 'Path' = $File; 'Format' = $Kind['Format']; 'Delimiter' = $Kind['Delimiter'] 'Root' = $null; 'Header' = @(); 'Newline' = [Environment]::NewLine; 'Text' = ''; 'Lost' = 0; 'New' = $false } $raw = '' if ([IO.File]::Exists($File)) { $bytes = [IO.File]::ReadAllBytes($File) try { $raw = $script:Utf8Strict.GetString($bytes) } catch { $raw = [System.Text.Encoding]::Default.GetString($bytes) # ANSI file; saved as UTF-8 } $raw = $raw.TrimStart([char]0xFEFF) if ($raw.Contains("`r`n")) { $script:Ed['Newline'] = "`r`n" } else { $script:Ed['Newline'] = "`n" } } else { $script:Ed['New'] = $true } if ($Kind['Format'] -eq 'json') { try { $script:Ed['Root'] = Read-EditJson $raw } catch { throw "$File is $($_.Exception.Message)" } } else { $ini = Read-EditIni $raw $Kind['Delimiter'] $script:Ed['Root'] = $ini['Root'] $script:Ed['Header'] = @($ini['Header']) $script:Ed['Lost'] = $ini['Lost'] } if (-not $script:Ed['New']) { $script:Ed['Text'] = Get-EditText } } ####################################################### # Interactive navigator ####################################################### function Write-EditLine { param([string]$Message = '') if ($script:InIse) { Write-Host $Message } else { [Console]::Out.WriteLine($Message) } } function Read-EditLine { param([string]$Prompt) try { $line = Read-Host $Prompt } catch { return $null } if ($null -eq $line) { return $null } return $line.Trim() } function Format-EditValue { param($Value) $kind = Get-EditKind $Value if ($kind -eq 'map') { return "{map: $($Value.PSBase.Count) entries}" } if ($kind -eq 'list') { return "[list: $(@($Value).Count) items]" } if ($kind -eq 'null') { return 'null' } if ($kind -eq 'string') { $s = [string]$Value if ($s.Length -gt 60) { $s = $s.Substring(0, 57) + '...' } return '"' + $s + '"' } return (ConvertTo-EditJson $Value) } function Show-EditNode { param($Stack) $names = @($Stack | ForEach-Object { $_['Name'] }) $node = $Stack[$Stack.Count - 1]['Node'] Write-EditLine '' Write-EditLine ('Path: ' + (@($names | Where-Object { $_ -ne '' }) -join ' > ') + $(if ($Stack.Count -eq 1) { ' (top)' } else { '' })) if ((Get-EditKind $node) -eq 'map') { $keys = @($node.PSBase.Keys) for ($i = 0; $i -lt $keys.Count; $i++) { Write-EditLine (' {0,3}. {1} = {2}' -f ($i + 1), $keys[$i], (Format-EditValue $node[$keys[$i]])) } if ($keys.Count -eq 0) { Write-EditLine ' (empty)' } } else { $items = @($node) for ($i = 0; $i -lt $items.Count; $i++) { Write-EditLine (' {0,3}. {1}' -f ($i + 1), (Format-EditValue $items[$i])) } if ($items.Count -eq 0) { Write-EditLine ' (empty list)' } } } function Show-EditHelp { Write-EditLine '' Write-EditLine ' N open entry N (a map or list), or change its value (Enter keeps the old one)' Write-EditLine ' a add an entry (a list item, or a new key in a map)' Write-EditLine ' d N delete entry N' Write-EditLine ' u go up one level' Write-EditLine ' w write the file, keep editing' Write-EditLine ' s write the file and quit' Write-EditLine ' q quit without writing (asks first when there are unsaved changes)' Write-EditLine ' v show the whole file as it would be written' Write-EditLine ' ? this help' } function Read-EditYes { param([string]$Prompt) $answer = Read-EditLine "$Prompt (y/N)" return ($null -ne $answer -and $answer.ToLowerInvariant().StartsWith('y')) } # prompts for a value of $Kind; $null when cancelled (empty answer keeps/cancels) function Read-EditValue { param([string]$Kind, [string]$Label) while ($true) { $hint = '' if ($Kind -eq 'bool') { $hint = ' (true or false)' } if ($Kind -eq 'number') { $hint = ' (a number)' } $answer = Read-EditLine "$Label$hint" if ($null -eq $answer) { return $null } if ($answer -eq '') { return $null } if ($answer -eq '-' -and $Kind -eq 'string') { return @{ 'Value' = '' } } $converted = Convert-EditInput $answer $Kind if ($converted['Ok']) { return @{ 'Value' = $converted['Value'] } } Write-EditLine " $($converted['Error'])" } } # asks which kind of entry to add; $null when cancelled function Read-EditKind { param([string]$Default, [bool]$AllowContainers) $menu = 'Type: [s]tring, [n]umber, [b]oolean' if ($AllowContainers) { $menu += ', [l]ist, [m]ap' } $answer = Read-EditLine "$menu (Enter = $Default, c = cancel)" if ($null -eq $answer -or $answer.ToLowerInvariant() -eq 'c') { return $null } if ($answer -eq '') { return $Default } $table = @{ 's' = 'string'; 'n' = 'number'; 'b' = 'bool'; 'l' = 'list'; 'm' = 'map' } $kind = $table[$answer.ToLowerInvariant().Substring(0, 1)] if ($null -eq $kind -or (($kind -eq 'list' -or $kind -eq 'map') -and -not $AllowContainers)) { Write-EditLine ' not a valid type here' return (Read-EditKind $Default $AllowContainers) } return $kind } # 'saved' or 'cancelled'; throws on a write problem function Save-EditSession { param([bool]$Interactive) $text = Get-EditText if ($script:Ed['Lost'] -gt 0 -and $Interactive) { Write-EditLine " Note: $($script:Ed['Lost']) comment or unrecognized line(s) inside the file are not kept." } $leaf = [IO.Path]::GetFileName($script:Ed['Path']) $warnings = Get-EditWarnings $script:Ed['Root'] $leaf if ($warnings.Count -gt 0) { foreach ($w in $warnings) { Write-EditLine " Warning: $w" } if ($Interactive) { if (-not (Read-EditYes 'Write it anyway?')) { return 'cancelled' } } elseif (-not $Force) { throw 'the known-key check failed (see above); nothing written. Use -Force to write anyway.' } } if (-not $script:Ed['New'] -and $text -ceq $script:Ed['Text']) { Write-EditLine ' No changes to write.' return 'saved' } Write-EditFile $script:Ed['Path'] $text $script:Ed['Newline'] $script:Ed['Text'] = $text $script:Ed['New'] = $false Write-EditLine " Written: $($script:Ed['Path'])" if (Test-Path -LiteralPath ($script:Ed['Path'] + '.bak')) { Write-EditLine " Previous contents: $($script:Ed['Path']).bak" } return 'saved' } function Test-EditChanged { if ($script:Ed['New']) { return $true } return ((Get-EditText) -cne $script:Ed['Text']) } function Invoke-EditNavigator { $stack = New-Object System.Collections.ArrayList [void]$stack.Add(@{ 'Name' = [IO.Path]::GetFileName($script:Ed['Path']); 'Node' = $script:Ed['Root'] }) $ini = ($script:Ed['Format'] -eq 'ini') Write-EditLine "dsEdit $($script:DsEditVersion): $($script:Ed['Path']) ($($script:Ed['Format'])) (? for help)" if ($script:Ed['New']) { Write-EditLine ' The file does not exist yet; it is created when you write.' } while ($true) { Show-EditNode $stack $node = $stack[$stack.Count - 1]['Node'] $isMap = ((Get-EditKind $node) -eq 'map') $command = Read-EditLine 'dsEdit' if ($null -eq $command) { return 'quit' } if ($command -eq '') { continue } $lower = $command.ToLowerInvariant() if ($lower -eq '?' -or $lower -eq 'h') { Show-EditHelp; continue } if ($lower -eq 'v') { try { $shown = Get-EditText } catch { Write-EditLine " Error: $($_.Exception.Message)"; continue } Write-EditLine '' Write-EditLine $shown continue } if ($lower -eq 'u') { if ($stack.Count -gt 1) { $stack.RemoveAt($stack.Count - 1) } else { Write-EditLine ' Already at the top.' } continue } if ($lower -eq 'q') { try { $changed = Test-EditChanged } catch { $changed = $true } if ($changed -and -not (Read-EditYes 'Discard unsaved changes?')) { continue } return 'quit' } if ($lower -eq 'w' -or $lower -eq 's') { try { $result = Save-EditSession $true } catch { Write-EditLine " Error: $($_.Exception.Message)"; continue } if ($result -eq 'saved' -and $lower -eq 's') { return 'saved' } continue } $keys = @() if ($isMap) { $keys = @($node.PSBase.Keys) } $count = if ($isMap) { $keys.Count } else { @($node).Count } if ($lower -eq 'a') { if ($isMap) { $name = Read-EditLine 'Name of the new entry (Enter cancels)' if ($null -eq $name -or $name -eq '') { continue } if ($name.Contains('.')) { Write-EditLine ' A name cannot contain a dot.'; continue } if ($node.Contains($name)) { Write-EditLine " '$name' already exists."; continue } if ($ini) { $problem = Get-EditIniKeyProblem $name $script:Ed['Delimiter'] $false if ($problem -ne '') { Write-EditLine " Not allowed in INI: $problem"; continue } } if ($ini) { # INI values are text; the only structure is a [section] at the top level $kind = 'string' if ($stack.Count -eq 1 -and (Read-EditYes 'Is this a [section] holding other entries?')) { $kind = 'map' } } else { $kind = Read-EditKind 'string' $true } } else { $default = 'string' if ($count -gt 0) { $first = Get-EditKind @($node)[0]; if ($first -ne 'null') { $default = $first } } $kind = Read-EditKind $default $true } if ($null -eq $kind) { continue } if ($kind -eq 'map') { $value = New-EditMap } elseif ($kind -eq 'list') { $value = New-Object System.Collections.ArrayList } else { $entered = Read-EditValue $kind 'Value (Enter cancels, - for empty text)' if ($null -eq $entered) { continue } $value = $entered['Value'] } if ($isMap) { $node[$name] = $value } else { [void]$node.Add($value) } continue } if ($command -match '^(d|delete)\s*(\d+)$') { $index = [int]$Matches[2] if ($index -lt 1 -or $index -gt $count) { Write-EditLine ' No such entry.'; continue } if (-not (Read-EditYes "Delete entry $index")) { continue } if ($isMap) { $node.Remove($keys[$index - 1]) } else { $node.RemoveAt($index - 1) } continue } if ($command -match '^\d+$') { $index = [int]$command if ($index -lt 1 -or $index -gt $count) { Write-EditLine ' No such entry.'; continue } if ($isMap) { $child = $node[$keys[$index - 1]]; $label = $keys[$index - 1] } else { $child = @($node)[$index - 1]; $label = "item $index" } $kind = Get-EditKind $child if ($kind -eq 'map' -or $kind -eq 'list') { [void]$stack.Add(@{ 'Name' = $label; 'Node' = $child }) continue } if ($kind -eq 'null') { $kind = 'string' } Write-EditLine " $label is $(Format-EditValue $child) ($kind)" $entered = Read-EditValue $kind 'New value (Enter keeps it, - for empty text)' if ($null -eq $entered) { continue } if ($isMap) { $node[$keys[$index - 1]] = $entered['Value'] } else { $node[$index - 1] = $entered['Value'] } continue } Write-EditLine ' Not understood; ? shows the commands.' } } ####################################################### # # Invoke-DsEditMain / Invoke-DsEdit # # Returns the exit code: 0 done, 2 failed. # ####################################################### function Invoke-DsEditMain { if ($PSVersionTable.PSVersion -lt [version]'3.0') { throw "PowerShell 3.0 or later required (this is $($PSVersionTable.PSVersion))" } $operations = @($Set).Count + @($Add).Count + @($Remove).Count $file = $Path if ($file -eq '' -and $ClientConfig) { $file = Get-EditClientConfigPath } if ($file -eq '') { $candidates = @() $client = Get-EditClientConfigPath if ($client -ne '') { if (@('ok', 'denied') -contains (Test-EditAccess $client $false)) { $candidates += $client } $serverInfo = [IO.Path]::Combine($env:ProgramData, 'camp', 'server.info') if (@('ok', 'denied') -contains (Test-EditAccess $serverInfo $false)) { $candidates += $serverInfo } } if ($operations -gt 0 -or $Show) { if ($candidates.Count -eq 0) { throw 'no file given (use -Path)' } $file = $candidates[0] } elseif ($candidates.Count -eq 1) { $file = $candidates[0] } else { for ($i = 0; $i -lt $candidates.Count; $i++) { Write-EditLine (' {0}. {1}' -f ($i + 1), $candidates[$i]) } $answer = Read-EditLine 'Number of the file to edit, or a path (Enter cancels)' if ($null -eq $answer -or $answer -eq '') { return 0 } if ($answer -match '^\d+$' -and [int]$answer -ge 1 -and [int]$answer -le $candidates.Count) { $file = $candidates[[int]$answer - 1] } else { $file = $answer } } } $file = Resolve-EditPath $file $kind = Get-EditFormat $file $Format $Delimiter if (Test-Path -LiteralPath $file -PathType Container) { throw "$file is a folder" } # the file is opened to decide: denied means elevate, missing means a new file (in a writable folder) $access = Test-EditAccess $file (-not ($Show -and $operations -eq 0)) if ($access -eq 'denied') { if ($NoElevate -or (Test-EditElevated) -or -not $script:OnWindows) { throw "access denied to $file; run as Administrator" } $exe = [IO.Path]::Combine($env:SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') try { Start-Process -FilePath $exe -ArgumentList (Get-EditRelaunchArguments $PSCommandPath $file) -Verb RunAs } catch { throw "could not start an elevated copy: $($_.Exception.Message)" } $script:PauseAtEnd = $false Write-EditLine 'An elevated copy was started in a new window (this window does not wait for it).' return 0 } elseif ($access -ne 'ok' -and $access -ne 'missing') { throw "cannot open ${file}: $access" } Open-EditFile $file $kind if ($operations -eq 0 -and -not $Show) { [void](Invoke-EditNavigator) return 0 } foreach ($spec in @($Set)) { Write-EditLine (Invoke-EditSet $script:Ed['Root'] $spec) } foreach ($spec in @($Add)) { Write-EditLine (Invoke-EditAdd $script:Ed['Root'] $spec) } foreach ($spec in @($Remove)) { Write-EditLine (Invoke-EditRemove $script:Ed['Root'] $spec) } if ($Show) { Write-EditLine (Get-EditText) } if ($operations -gt 0) { [void](Save-EditSession $false) } return 0 } function Invoke-DsEdit { $code = 0 try { $code = @(Invoke-DsEditMain)[-1] } catch { if ($script:InIse) { Write-Host "dsEdit: $($_.Exception.Message)" -ForegroundColor Red } else { [Console]::Error.WriteLine("dsEdit: $($_.Exception.Message)") } $code = 2 } if ($script:PauseAtEnd) { try { [void](Read-Host 'Press Enter to close') } catch { } } return $code } if ($MyInvocation.InvocationName -ne '.') { exit (Invoke-DsEdit) }