Have you ever accidentally overwritten a powershell script on disk? Didn't have a versioned copy in source control? Let's see if PowerShell can help us recover from such an incident...
Lesson of the day: If you write a PowerShell script that writes a transcript to the exact script name, the script will overwrite itself with a log, ceasing to exist.
— Michael Niehaus (@mniehaus) July 25, 2020
Ouch. That's unfortunate. Especially if you don't have a versioned copy of the script. But as Jason Shirk noted - if the hosting process is still running when you realize it, you actually have a chance at recovering the script:
If not, if the process is still running, the text is cached in the ScriptBlock cache.
— 𝙹𝚊𝚜𝚘𝚗 𝚂𝚑𝚒𝚛𝚔 (@lzybkr) July 25, 2020
So... how might one do that, exactly? Let's try and see if we can reproduce the original problem, and then extract the script from this "scriptblock cache".
Sawing through the branch you're sitting on
Reproducing the situation described in the original tweet is as simple as putting the following two lines of code in a *.ps1 script file anywhere on disk:
Start-Transcript -Path $PSCommandPath
Stop-Transcript
So let's place that in a file in the current directory, execute the script, and see what happens:
PS C:\Users\IISResetMe> @'
>> Start-Transcript -Path $PSCommandPath
>> Stop-Transcript
>> '@ |Set-Content replace.ps1
PS C:\Users\IISResetMe> Get-Content replace.ps1
Start-Transcript -Path $PSCommandPath
Stop-Transcript
PS C:\Users\IISResetMe> .\replace.ps1
Transcript started, output file is C:\Users\IISResetMe\replace.ps1
Transcript stopped, output file is C:\Users\IISResetMe\replace.ps1
And sure enough, we've now overwritten the script with a transcript log of it's own execution - oops!
Accessing the ScriptBlock cache
There's no way to directly access the scriptblock cache through the public API - so we'll need a bit of reflection magic to unearth the cached version of our script.
The backing field for the scriptblocks in the cache is a static dictionary owned by the [scriptblock]
class. In Windows PowerShell we can access it like so:
$cachedScriptsMemberInfo = [scriptblock].GetMember('_cachedScripts', 'Static,NonPublic')[0]
$cache = $cachedScriptsMemberInfo.GetValue($null)
$cache
will now hold a reference to the dictionary backing the script cache - mapping keys consisting of (scriptPath, content)
to a compiled [scriptblock]
instance. To find the key corresponding to our script, let's enumerate all the keys and filter for the expected path against the first item in each tuple:
PS C:\Users\IISResetMe> $relevantKeys = $cache.Keys.Where({$_.Item1 -like '*\replace.ps1'})
PS C:\Users\IISResetMe> $relevantKeys |Select Item1,Item2 |Format-List
Item1 : C:\Users\IISResetMe\replace.ps1
Item2 : Start-Transcript -Path $PSCommandPath
Stop-Transcript
Boom, script recovered!
Making it work for PowerShell core
The scriptblock caching facility hasn't changed much over time, so we can re-use almost the same trick for PowerShell Core or PowerShell 7 - just change the field name to s_cachedScripts
instead:
function Get-ScriptBlockCache
{
param()
$fieldName = '_cachedScripts'
if($PSVersionTable.PSVersion.Major -ge 6){
$fieldName = 's_cachedScripts'
}
$cachedScriptsMemberInfo = [scriptblock].GetMember($fieldName, 'Static,NonPublic')[0]
# We're only interested in the keys, since they contain the original script text
return $cachedScriptsMemberInfo.GetValue($null).Keys
}
Now we can simply do Get-ScriptBlockCache |Where-Object Item1 -like *replace.ps1 |Format-List
The scriptblock cache implementation can be found in CompiledScriptBlock.cs
, right here.