Skip to content

Instantly share code, notes, and snippets.

@mklement0
Last active March 30, 2024 12:03
Show Gist options
  • Save mklement0/006c2352ddae7bb05693be028240f5b6 to your computer and use it in GitHub Desktop.
Save mklement0/006c2352ddae7bb05693be028240f5b6 to your computer and use it in GitHub Desktop.
PowerShell module for reading and writing INI files on Windows, via the Windows API
<#
Windows-only module for reading from / updating INI files, via the Windows API.
You can either:
* download this script module (IniFileHelper.psm1) and use Import-Module to import it into a session;
If you place it in a subdirectory named IniFileHelper in in one of the directories listed in $env:PSModulePath,
it will automatically be available in future sessions.
* for ad-hoc use, download the code and create a dynamic, transient module for the
current session, as follows (remove -Verbose to make the import silent):
$null = New-Module -Verbose -ScriptBlock ([scriptblock]::Create((Invoke-RestMethod 'https://gist.githubusercontent.com/mklement0/006c2352ddae7bb05693be028240f5b6/raw/1e2520810213f76f2e8f419d0e48892a4009de6a/IniFileHelper.psm1')))
Either way, a helper .NET type [net.same2u.WinApiHelper.IniFile] is on-demand-compiled on import,
which incurs a one-time performanc penalty per session.
Module functions:
* Get-IniValue returns a specific value for a given section and key from an INI file, invariably as a string.
It optionally enumerates all section names or all key names inside a given section.
* Set-IniValue updates a specific value for a given section and key from an INI file.
It optionally deletes an entry or even an entire section of entries.
If the INI file doesn't exist yet, it is created on demand with UTF-16LE ("Unicode") encoding.
A preexisting INI file that doesn't have a UTF-16LE BOM is invariably treated as ANSI-encoded.
Invoke the functions with Get-Help -Examples to learn more.
Requires PowerShell version 3 or higher.
These functions were inspired by https://stackoverflow.com/a/55437750/45375
#>
# Check prerequisites:
# PS version:
# IMPORTANT: Be sure to make any modifications PSv3-compatible, which notably requires avoiding use of ::new()
# v3 is required at a minimum due to use of [NullString]::Value
#requires -Version 3
# Windows-only.
if ($env:OS -ne 'Windows_NT') { Throw "This module is only supported on Windows." }
# Create a helper .NET type that wraps P/Invoke calls to the Get/WritePrivateProfileString()
# Windows API functions.
Add-Type -Namespace net.same2u.WinApiHelper -Name IniFile -MemberDefinition @'
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
// Note the need to use `[Out] byte[]` instead of `System.Text.StringBuilder` in order to support strings with embedded NUL chars.
public static extern uint GetPrivateProfileString(string lpAppName, string lpKeyName, string lpDefault, [Out] byte[] lpBuffer, uint nSize, string lpFileName);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
public static extern bool WritePrivateProfileString(string lpAppName, string lpKeyName, string lpString, string lpFileName);
'@
Function Get-IniValue {
<#
.SYNOPSIS
Gets a given entry's value from an INI file, as a string.
Optionally *enumerates* elements of the file:
* section names (if -Section is omitted)
* entry keys in a given section (if -Key is omitted)
can be returned.
.EXAMPLE
Get-IniValue file.ini section1 key1
Returns the value of key key1 from section section1 in file file.ini.
Get-IniValue file.ini section1 key1 defaultVal1
Returns the value of key key1 from section section1 in file file.ini
and returns 'defaultVal1' if no such key exists.
.EXAMPLE
Get-IniValue file.ini section1
Returns the names of all keys in section section1 in file file.ini.
.EXAMPLE
Get-IniValue file.ini
Returns the names of all sections in file file.ini.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $LiteralPath,
[string] $Section,
[string] $Key,
[string] $DefaultValue
)
# Determine if no section name and/or no key name was passed,
# in which case enumeration of section / key names is performed.
$noSection = -not $PSBoundParameters.ContainsKey('Section')
$noKey = -not $PSBoundParameters.ContainsKey('Key')
$noDefaultValue = -not $PSBoundParameters.ContainsKey('DefaultValue')
$enumerate = $noSection -or $noKey
if (-not $noKey -and $noSection) {
Write-Warning "Ignoring -Key argument in the absence of a -Section argument."
$noKey = $true
} elseif (-not $noDefaultValue -and $noKey) {
Write-Warning "Ignoring -DefaultValue argument in the absence of a -Key argument."
$noDefaultValue = $true
}
# Convert the path to an *absolute* one, since .NET's and the WinAPI's
# current dir. is usually differs from PowerShell's.
$fullPath = Convert-Path -ErrorAction Stop -LiteralPath $LiteralPath
$bufferCharCount = 0
$bufferChunkSize = 1024 # start with reasonably large default value.
do {
$bufferCharCount += $bufferChunkSize
# Note: We MUST use raw byte buffers, because [System.Text.StringBuilder] doesn't support
# returning values with embedded NULs - see https://stackoverflow.com/a/15274893/45375
$buffer = New-Object byte[] ($bufferCharCount * 2)
# Note: The return value is the number of bytes copied excluding the trailing NUL / double NUL
# It is only ever 0 if the buffer char. count is pointlessly small (1 with single NUL, 2 with double NUL)
# IMPORTANT:
# [NullString]::Value must be passed to signal the absence of a section and/or key name.
# Originally, we assigned [NullString]::Value directly to the $Section and/or $Key variables,
# but a BUG, unearthed by pierre.bru@fr.airbus.com, made that ineffective once a debugger is loaded
# into the session - see https://github.com/PowerShell/PowerShell/issues/21234
# Therefore, we use conditionals here and pass [NullString]::Value *explicitly*.
$copiedCharCount = [net.same2u.WinApiHelper.IniFile]::GetPrivateProfileString(
($Section, [NullString]::Value)[$noSection],
($Key, [NullString]::Value)[$noKey],
($DefaultValue, [NullString]::Value)[$noDefaultValue],
$buffer,
$bufferCharCount,
$fullPath
)
} while ($copiedCharCount -ne 0 -and $copiedCharCount -eq $bufferCharCount - (1, 2)[$enumerate]) # Check to see if the full value was retrieved or whether the buffer was too small.
# Convert the byte buffer contents back to a string.
if ($copiedCharCount -eq 0) {
# Nothing was copied (non-existent section or entry or empty value) - return the empty string.
''
} else {
# If entries are being enumerated (if -Section or -Key were omitted),
# the resulting string must be split by embedded NUL chars. to return the enumerated values as an *array*
# If a specific value is being retrieved, this splitting is an effective no-op.
[Text.Encoding]::Unicode.GetString($buffer, 0, ($copiedCharCount - (0, 1)[$enumerate]) * 2) -split "`0"
}
}
Function Set-IniValue {
<#
.SYNOPSIS
Updates a given entry's value in an INI file.
Optionally *deletes* from the file:
* an entry (if -Value is omitted)
* a entire section (if -Key is omitted)
If the target file doesn't exist yet, it is created on demand,
with UTF-16LE enoding (the target file's diretory must already exist).
A preexisting file that doesn't have a UTF-16LE BOM is invariably
treated as ANSI-encoded.
.EXAMPLE
Set-IniValue file.ini section1 key1 value1
Updates the value of the entry whose key is key1 in section section1 in file
file.ini.
.EXAMPLE
Set-IniValue file.ini section1 key1
Deletes the entry whose key is key1 from section section1 in file file.ini.
.EXAMPLE
Set-IniValue file.ini section1
Deletes the entire section section1 from file file.ini.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $LiteralPath,
[Parameter(Mandatory)] [string] $Section,
[string] $Key,
[string] $Value
)
# Determine if no key name and/or no value was passed,
# which serves as the signal to *delete* a whole section or
# a key (entry) inside a section.
$noKey = -not $PSBoundParameters.ContainsKey('Key')
$noValue = -not $PSBoundParameters.ContainsKey('Value')
if (-not $noValue -and $noKey) {
Write-Warning "Ignoring -Value argument in the absence of a -Key argument."
$noValue = $true
}
# Convert the path to an *absolute* one, since .NET's and the WinAPI's
# current dir. is usually differs from PowerShell's.
$fullPath =
try {
Convert-Path -ErrorAction Stop -LiteralPath $LiteralPath
} catch {
# Presumably, the file doesn't exist, so we create it on demand, as WriteProfileString() would,
# EXCEPT that we want to create a "Unicode" (UTF-16LE) file, whereas WriteProfileString()
# - even when calling the Unicode version - ceates an *ANSI* file.
# Note: As WriteProfileString() does, we require that the *directory* for the new file alreay exist.
Set-Content -ErrorAction Stop -Encoding Unicode -LiteralPath $LiteralPath -Value @()
(Get-Item -LiteralPath $LiteralPath).FullName # Output the full, native path.
}
# IMPORTANT:
# [NullString]::Value must be passed explicitly to work around a bug.
# See the comments in Get-IniValue above.
$ok = [net.same2u.WinApiHelper.IniFile]::WritePrivateProfileString(
$Section,
($Key, [NullString]::Value)[$noKey],
($Value, [NullString]::Value)[$noValue],
$fullPath
)
if (-not $ok) { Throw "Updating INI file failed: $fullPath" }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment