dsh-autorestart 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,508 @@
1
+ <#
2
+ .SYNOPSIS
3
+ The dsh-autorestart rescue drill: break a profile on purpose, rescue it.
4
+
5
+ .DESCRIPTION
6
+ This script only touches ITS OWN temporary directory. It never reads or
7
+ writes the profile the user is running.
8
+
9
+ Directory discipline (the hard requirement for running two drills at once):
10
+
11
+ - the directory is %TEMP%\dsh-rescue-drill-<full GUID>, unique every run;
12
+ - the drill creates it itself (without -Force): if it is already there,
13
+ somebody else is using it and the drill stops;
14
+ - the drill puts a lock file .lock inside it (created exclusively): no lock,
15
+ no drill;
16
+ - the cleanup deletes ONLY its own absolute path (Remove-Item -LiteralPath),
17
+ never a wildcard scan of %TEMP% - that would delete the directory of a
18
+ drill that is running at the same time;
19
+ - success, a failed assertion and a crash all go through the same finally:
20
+ the directory is always cleaned, and a directory that could not be
21
+ cleaned is printed so a human can remove it.
22
+
23
+ What the drill does:
24
+
25
+ 1. builds a fake profile (package.json + cordis.patch.yml + plugin tree);
26
+ 2. builds a snapshot (the "backup source") plus a NEWER directory that has
27
+ no manifest - the snapshot selection has to skip it;
28
+ 3. breaks it on purpose: package.json becomes invalid JSON and the plugin
29
+ directory is deleted;
30
+ 4. calls restore-profile.ps1;
31
+ 5. asserts: package.json parses, the bundles have content, the plugin
32
+ version can be read, the broken-* evidence directory exists, a directory
33
+ that is not a profile is refused, a missing target is refused, "the
34
+ configuration says it is installed but it is not" must fail, a drive root
35
+ is refused, a system directory is refused, a dry run changes nothing,
36
+ the encoding rules hold;
37
+ 6. cleans up (finally).
38
+
39
+ LANGUAGE: this file is pure ASCII on purpose. Windows PowerShell 5.1 reads a
40
+ UTF-8 file without a byte order mark as ANSI, so one Chinese character
41
+ inside the script turns into mojibake and the script dies with a ParserError
42
+ before it does anything. The Chinese wording lives in
43
+ scripts\rescue-strings.zh.json (UTF-8, no BOM) and is read at runtime; if
44
+ that file is missing or broken, every message falls back to English and the
45
+ script says so.
46
+
47
+ .EXAMPLE
48
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\drill-rescue.ps1
49
+
50
+ .NOTES
51
+ Every assertion passing means exit code 0; any failing assertion means 1.
52
+ #>
53
+ [CmdletBinding()]
54
+ param()
55
+
56
+ $ErrorActionPreference = 'Stop'
57
+
58
+ # The plugin the drill works with.
59
+ $PackageName = 'dsh-autorestart'
60
+
61
+ # How many assertions failed (the exit code is decided by it).
62
+ $script:Failures = 0
63
+
64
+ # This run's own directory, and whether this run is the one that created it.
65
+ $script:TempRoot = ''
66
+ $script:OwnsTempRoot = $false
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # The Chinese strings: read from rescue-strings.zh.json, fall back to English.
70
+ # ---------------------------------------------------------------------------
71
+
72
+ $script:Strings = $null
73
+ $script:StringsError = ''
74
+
75
+ function Initialize-Strings {
76
+ $path = Join-Path $PSScriptRoot 'rescue-strings.zh.json'
77
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
78
+ $script:StringsError = (T 'common.stringsMissingEn' 'the Chinese strings file is missing: {0} (falling back to English messages)') -f $path
79
+ return
80
+ }
81
+ try {
82
+ # Read as UTF-8 explicitly: Get-Content would use the console code page.
83
+ $text = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
84
+ $script:Strings = $text | ConvertFrom-Json
85
+ } catch {
86
+ $script:StringsError = (T 'common.stringsBrokenEn' 'the Chinese strings file could not be read: {0} (falling back to English messages)') -f $_.Exception.Message
87
+ $script:Strings = $null
88
+ }
89
+ }
90
+
91
+ # The node behind a dotted key, or $null when the key does not exist.
92
+ function Get-StringNode {
93
+ param([string] $Key)
94
+ if ($null -eq $script:Strings) { return $null }
95
+ $node = $script:Strings
96
+ foreach ($part in $Key.Split('.')) {
97
+ if ($null -eq $node) { return $null }
98
+ $property = $node.PSObject.Properties[$part]
99
+ if ($null -eq $property) { return $null }
100
+ $node = $property.Value
101
+ }
102
+ return $node
103
+ }
104
+
105
+ # Look a string up. The English fallback is right here in the script, so a
106
+ # missing key (or a broken strings file) never stops the drill.
107
+ function T {
108
+ param([string] $Key, [string] $Fallback)
109
+ $node = Get-StringNode $Key
110
+ if ($null -ne $node) {
111
+ $text = [string] $node
112
+ if ($text -ne '') { return $text }
113
+ }
114
+ return $Fallback
115
+ }
116
+
117
+ # The same, for a list of strings (the words a refusal has to contain).
118
+ function TA {
119
+ param([string] $Key, [string[]] $Fallback)
120
+ $node = Get-StringNode $Key
121
+ if ($null -ne $node) {
122
+ $items = @($node)
123
+ if ($items.Count -gt 0) { return $items }
124
+ }
125
+ return $Fallback
126
+ }
127
+
128
+ Initialize-Strings
129
+
130
+ function Say {
131
+ param([string] $Text, [string] $Color = 'Gray')
132
+ Write-Host $Text -ForegroundColor $Color
133
+ }
134
+
135
+ function Assert-True {
136
+ param([string] $Name, [bool] $Condition, [string] $Detail = '')
137
+ if ($Condition) {
138
+ Write-Host ((T 'drill.assertPass' ' [pass] {0}') -f $Name) -ForegroundColor Green
139
+ return
140
+ }
141
+ if ($Detail -eq '') {
142
+ Write-Host ((T 'drill.assertFailPlain' ' [fail] {0}') -f $Name) -ForegroundColor Red
143
+ } else {
144
+ Write-Host ((T 'drill.assertFail' ' [fail] {0} - {1}') -f $Name, $Detail) -ForegroundColor Red
145
+ }
146
+ $script:Failures += 1
147
+ }
148
+
149
+ # A rejection has to be more than a non-zero exit code.
150
+ #
151
+ # An exit code on its own is not enough: a missing BOM, a broken script, a
152
+ # syntax error - all of those make the script exit non-zero without it having
153
+ # refused anything. That is how the BOM incident of 2026-10-29 produced a
154
+ # false pass: the screen was full of ParserErrors, the exit code was 1, and
155
+ # that looked like "the script refused on purpose".
156
+ # So the output has to contain the sentence that belongs to this scenario.
157
+ function Assert-Refused {
158
+ param([string] $Name, $Result, [string[]] $Keywords)
159
+ if ($null -eq $Result) {
160
+ Write-Host ((T 'drill.refusedNoResult' ' [fail] {0} - there is no result to look at') -f $Name) -ForegroundColor Red
161
+ $script:Failures += 1
162
+ return
163
+ }
164
+ if ($Result.Code -eq 0) {
165
+ Write-Host ((T 'drill.refusedNotRefused' ' [fail] {0} - the exit code is 0, the script did not refuse at all') -f $Name) -ForegroundColor Red
166
+ $script:Failures += 1
167
+ return
168
+ }
169
+ $text = [string]$Result.Output
170
+ $hit = ''
171
+ foreach ($keyword in $Keywords) {
172
+ if ($text -like ('*' + $keyword + '*')) { $hit = $keyword; break }
173
+ }
174
+ if ($hit -eq '') {
175
+ Write-Host ((T 'drill.refusedNoReason' ' [fail] {0} - the exit code is {1}, but the output does not say why it refused (one of these was expected: {2})') -f $Name, $Result.Code, ($Keywords -join ', ')) -ForegroundColor Red
176
+ $script:Failures += 1
177
+ return
178
+ }
179
+ Write-Host ((T 'drill.refusedOk' ' [pass] {0} - exit code {1}, the output says why: {2}') -f $Name, $Result.Code, $hit) -ForegroundColor Green
180
+ }
181
+
182
+ # Read a file; an unreadable file gives an empty string (an assertion must
183
+ # never take the drill down with it).
184
+ function Read-TextOrEmpty {
185
+ param([string] $Path)
186
+ try {
187
+ return [System.IO.File]::ReadAllText($Path)
188
+ } catch {
189
+ return ''
190
+ }
191
+ }
192
+
193
+ # Write a UTF-8 file without a byte order mark (the parent directory is
194
+ # created on the way).
195
+ function Write-TextFile {
196
+ param([string] $Path, [string] $Text)
197
+ $parent = Split-Path -Parent $Path
198
+ if ($parent -ne '' -and -not (Test-Path -LiteralPath $parent)) {
199
+ New-Item -ItemType Directory -Path $parent -Force | Out-Null
200
+ }
201
+ [System.IO.File]::WriteAllText($Path, $Text, (New-Object System.Text.UTF8Encoding($false)))
202
+ }
203
+
204
+ $scriptRoot = $PSScriptRoot
205
+ $restoreScript = Join-Path $scriptRoot 'restore-profile.ps1'
206
+ $cmdScript = Join-Path $scriptRoot 'restore-profile.cmd'
207
+
208
+ if (-not (Test-Path -LiteralPath $restoreScript -PathType Leaf)) {
209
+ Write-Host ((T 'drill.noScript' 'the drill cannot run: {0} was not found') -f $restoreScript) -ForegroundColor Red
210
+ exit 1
211
+ }
212
+
213
+ # A full GUID: every run gets a new directory, two drills never collide.
214
+ $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('dsh-rescue-drill-' + ([guid]::NewGuid().ToString('N')))
215
+ $script:TempRoot = $tempRoot
216
+ # The lock file lives INSIDE the directory: when the directory is gone the lock
217
+ # is gone too, so %TEMP% never keeps a leftover with the same prefix.
218
+ $lockFile = Join-Path $tempRoot '.lock'
219
+
220
+ # Re-entry guard 1: the directory is already there, so somebody (or a run of
221
+ # ours that did not clean up) is using it.
222
+ if (Test-Path -LiteralPath $tempRoot) {
223
+ Write-Host ((T 'drill.dirOccupied' 'the drill cannot run: this drill directory is already taken: {0}') -f $tempRoot) -ForegroundColor Red
224
+ exit 1
225
+ }
226
+
227
+ # Run the restore script.
228
+ function Invoke-Restore {
229
+ param([string[]] $Arguments, [string] $Label)
230
+ $previous = $ErrorActionPreference
231
+ $ErrorActionPreference = 'Continue'
232
+ $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $restoreScript @Arguments 2>&1
233
+ $code = $LASTEXITCODE
234
+ $ErrorActionPreference = $previous
235
+ Say ((T 'drill.outputHeader' '--- the output of the restore script ({0}) ---') -f $Label) 'DarkGray'
236
+ foreach ($line in $output) { Write-Host (' ' + [string]$line) }
237
+ Say (T 'drill.outputEnd' '--- end of the output ---') 'DarkGray'
238
+ return [pscustomobject]@{ Code = $code; Output = ($output | Out-String) }
239
+ }
240
+
241
+ try {
242
+ # --- Re-entry guard 2: this drill creates the directory itself (without
243
+ # -Force, so an existing directory is an error). ---
244
+ New-Item -ItemType Directory -Path $tempRoot -ErrorAction Stop | Out-Null
245
+
246
+ # --- Re-entry guard 3: the lock file is created exclusively. ---
247
+ $lock = [System.IO.File]::Open($lockFile, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
248
+ $lock.Dispose()
249
+ $script:OwnsTempRoot = $true
250
+
251
+ Say ((T 'drill.dir' 'drill directory: {0}') -f $tempRoot) 'Cyan'
252
+
253
+ $fakeHome = Join-Path $tempRoot 'dsh-home'
254
+ $stateDir = Join-Path (Join-Path $fakeHome 'storages') 'dsh-autorestart'
255
+ $backupsDir = Join-Path $stateDir 'backups'
256
+ $profileDir = Join-Path $tempRoot 'profile'
257
+ $pluginDir = Join-Path (Join-Path $profileDir 'node_modules') $PackageName
258
+
259
+ # -------------------------------------------------------------------------
260
+ # 1. Build a fake profile
261
+ # -------------------------------------------------------------------------
262
+
263
+ New-Item -ItemType Directory -Path $pluginDir -Force | Out-Null
264
+ New-Item -ItemType Directory -Path $backupsDir -Force | Out-Null
265
+
266
+ $goodPackageJson = @'
267
+ {
268
+ "name": "drill-profile",
269
+ "version": "1.0.0",
270
+ "dsh": {
271
+ "profile": {
272
+ "bundles": ["dsh-autorestart"]
273
+ }
274
+ }
275
+ }
276
+ '@
277
+ Write-TextFile (Join-Path $profileDir 'package.json') $goodPackageJson
278
+ Write-TextFile (Join-Path $profileDir 'cordis.patch.yml') "rows:`n - id: drill`n"
279
+ Write-TextFile (Join-Path $pluginDir 'package.json') "{`n `"name`": `"dsh-autorestart`",`n `"version`": `"0.7.0`"`n}"
280
+ Write-TextFile (Join-Path $pluginDir 'index.js') "export default 1`n"
281
+
282
+ # -------------------------------------------------------------------------
283
+ # 2. Build the snapshot (the backup source) plus a newer directory without a
284
+ # manifest - the snapshot selection has to skip it.
285
+ # -------------------------------------------------------------------------
286
+
287
+ $goodStamp = '2026-10-29T01-00-00-000Z'
288
+ $decoyStamp = '2026-10-29T02-00-00-000Z'
289
+ $goodSnapshot = Join-Path $backupsDir $goodStamp
290
+ $decoySnapshot = Join-Path $backupsDir $decoyStamp
291
+ $goodPluginDir = Join-Path (Join-Path $goodSnapshot 'node_modules') $PackageName
292
+
293
+ New-Item -ItemType Directory -Path (Join-Path $goodSnapshot 'config') -Force | Out-Null
294
+ New-Item -ItemType Directory -Path $goodPluginDir -Force | Out-Null
295
+ Copy-Item -LiteralPath (Join-Path $profileDir 'package.json') -Destination (Join-Path $goodSnapshot 'config\package.json') -Force
296
+ Copy-Item -LiteralPath (Join-Path $profileDir 'cordis.patch.yml') -Destination (Join-Path $goodSnapshot 'config\cordis.patch.yml') -Force
297
+ Copy-Item -LiteralPath (Join-Path $pluginDir 'package.json') -Destination (Join-Path $goodPluginDir 'package.json') -Force
298
+ Copy-Item -LiteralPath (Join-Path $pluginDir 'index.js') -Destination (Join-Path $goodPluginDir 'index.js') -Force
299
+
300
+ $manifest = [ordered]@{
301
+ stamp = $goodStamp
302
+ at = [int64] 1792000000000
303
+ atLocal = '2026-10-29T09:00:00+08:00'
304
+ profileDir = $profileDir
305
+ includePlugins = $true
306
+ files = @('config\package.json', 'config\cordis.patch.yml', 'node_modules')
307
+ bytes = 128
308
+ }
309
+ Write-TextFile (Join-Path $goodSnapshot 'manifest.json') ($manifest | ConvertTo-Json -Depth 5)
310
+
311
+ # The decoy: a newer directory, but no manifest - and a different package.json.
312
+ New-Item -ItemType Directory -Path (Join-Path $decoySnapshot 'config') -Force | Out-Null
313
+ Write-TextFile (Join-Path $decoySnapshot 'config\package.json') '{"name":"should-not-be-used"}'
314
+
315
+ # -------------------------------------------------------------------------
316
+ # 3. Break it on purpose
317
+ # -------------------------------------------------------------------------
318
+
319
+ Write-TextFile (Join-Path $profileDir 'package.json') '{ "name": "drill-profile", oops '
320
+ Remove-Item -LiteralPath $pluginDir -Recurse -Force
321
+
322
+ Say (T 'drill.broke' 'broken on purpose: package.json is invalid JSON now and the plugin directory is gone.') 'Yellow'
323
+
324
+ # -------------------------------------------------------------------------
325
+ # Run the restore script
326
+ # -------------------------------------------------------------------------
327
+
328
+ # The dry run: it only prints.
329
+ $dryRun = Invoke-Restore -Arguments @('-DryRun', '-ProfileDir', $profileDir, '-StateDir', $stateDir) -Label 'dry run'
330
+ Assert-True (T 'drill.assertDryRunExit' 'the dry run exits with 0') ($dryRun.Code -eq 0) ('exit code ' + $dryRun.Code)
331
+ Assert-True (T 'drill.assertDryRunUntouched' 'the dry run did not touch package.json') ((Read-TextOrEmpty (Join-Path $profileDir 'package.json')) -like '*oops*')
332
+ Assert-True (T 'drill.assertDryRunNoBroken' 'the dry run left no broken directory') (@(Get-ChildItem -LiteralPath $tempRoot -Directory -Filter 'profile.broken-*').Count -eq 0)
333
+
334
+ # The real restore.
335
+ $restore = Invoke-Restore -Arguments @('-ProfileDir', $profileDir, '-StateDir', $stateDir) -Label 'the real restore'
336
+ Assert-True (T 'drill.assertRestoreExit' 'the restore exits with 0') ($restore.Code -eq 0) ('exit code ' + $restore.Code)
337
+
338
+ # -------------------------------------------------------------------------
339
+ # 4. The assertions
340
+ # -------------------------------------------------------------------------
341
+
342
+ Say (T 'drill.assertions' 'the assertions:') 'Cyan'
343
+
344
+ # 4.1 The Chinese wording really comes out of the JSON.
345
+ $hasChinese = [regex]::IsMatch([string]$restore.Output, '[\u4e00-\u9fff]')
346
+ Assert-True (T 'drill.assertChineseOutput' 'the output of the restore script contains Chinese (the wording is read from the JSON)') $hasChinese
347
+
348
+ # 4.2 package.json parses.
349
+ $restoredPackage = $null
350
+ $parseError = ''
351
+ try {
352
+ $restoredPackage = ([System.IO.File]::ReadAllText((Join-Path $profileDir 'package.json'))) | ConvertFrom-Json
353
+ } catch {
354
+ $parseError = $_.Exception.Message
355
+ }
356
+ Assert-True (T 'drill.assertParses' 'package.json parses after the restore') ($null -ne $restoredPackage) $parseError
357
+
358
+ # 4.3 The snapshot with the manifest was used, not the newer decoy.
359
+ $restoredName = ''
360
+ if ($null -ne $restoredPackage) { $restoredName = [string] $restoredPackage.name }
361
+ Assert-True (T 'drill.assertUsedSnapshot' 'the snapshot with the manifest was used (the newer decoy was skipped)') ($restoredName -eq 'drill-profile') ('the name that came back is "' + $restoredName + '"')
362
+
363
+ # 4.4 The bundles have content.
364
+ $bundles = @()
365
+ if ($null -ne $restoredPackage -and $null -ne $restoredPackage.dsh -and $null -ne $restoredPackage.dsh.profile -and $null -ne $restoredPackage.dsh.profile.bundles) {
366
+ $bundles = @($restoredPackage.dsh.profile.bundles)
367
+ }
368
+ Assert-True (T 'drill.assertBundles' 'dsh.profile.bundles has content after the restore') ($bundles.Count -gt 0) ('bundles: ' + $bundles.Count)
369
+
370
+ # 4.5 The plugin version can be read.
371
+ $pluginVersion = ''
372
+ $pluginPkgPath = Join-Path $pluginDir 'package.json'
373
+ if (Test-Path -LiteralPath $pluginPkgPath -PathType Leaf) {
374
+ try {
375
+ $pluginPkg = ([System.IO.File]::ReadAllText($pluginPkgPath)) | ConvertFrom-Json
376
+ if ($null -ne $pluginPkg -and $null -ne $pluginPkg.version) { $pluginVersion = ([string] $pluginPkg.version).Trim() }
377
+ } catch {
378
+ $pluginVersion = ''
379
+ }
380
+ }
381
+ Assert-True (T 'drill.assertVersionReadable' 'the plugin version can be read after the restore') ($pluginVersion -ne '') ('version "' + $pluginVersion + '"')
382
+ Assert-True (T 'drill.assertVersionSame' 'the plugin version is the one of the snapshot') ($pluginVersion -eq '0.7.0') ('version "' + $pluginVersion + '"')
383
+
384
+ # 4.6 The broken evidence directory exists and still holds the broken one.
385
+ $brokenDirs = @(Get-ChildItem -LiteralPath $tempRoot -Directory -Filter 'profile.broken-*' -ErrorAction SilentlyContinue)
386
+ Assert-True (T 'drill.assertBrokenExists' 'the broken evidence directory exists') ($brokenDirs.Count -ge 1) ('found ' + $brokenDirs.Count)
387
+ if ($brokenDirs.Count -ge 1) {
388
+ $brokenPackagePath = Join-Path $brokenDirs[0].FullName 'package.json'
389
+ $brokenKeeps = (Test-Path -LiteralPath $brokenPackagePath -PathType Leaf) -and ((Read-TextOrEmpty $brokenPackagePath) -like '*oops*')
390
+ Assert-True (T 'drill.assertBrokenKeeps' 'the broken directory still holds the broken one') $brokenKeeps
391
+ }
392
+
393
+ # 4.7 A directory that does not look like a profile has to be refused, with
394
+ # a reason.
395
+ $notAProfile = Join-Path $tempRoot 'not-a-profile'
396
+ New-Item -ItemType Directory -Path $notAProfile -Force | Out-Null
397
+ $refused = Invoke-Restore -Arguments @('-ProfileDir', $notAProfile, '-StateDir', $stateDir) -Label 'a directory that is not a profile'
398
+ Assert-Refused (T 'drill.assertNotProfileRefused' 'a directory that is not a profile is refused (exit code + reason)') $refused (TA 'drill.keywords.notProfile' @('does not look like a DSH profile'))
399
+ Assert-True (T 'drill.assertNotProfileUntouched' 'the refused directory was not touched') (@(Get-ChildItem -LiteralPath $tempRoot -Directory -Filter 'not-a-profile.broken-*').Count -eq 0)
400
+
401
+ # 4.8 A drive root has to be refused, with a reason.
402
+ $rootRefused = Invoke-Restore -Arguments @('-ProfileDir', 'C:\', '-StateDir', $stateDir) -Label 'a drive root'
403
+ Assert-Refused (T 'drill.assertDriveRootRefused' 'a drive root is refused (exit code + reason)') $rootRefused (TA 'drill.keywords.driveRoot' @('drive root'))
404
+
405
+ # 4.9 The system directories have to be refused, with a reason (Windows /
406
+ # Program Files / ProgramData). The script refuses them before it
407
+ # touches them - this only proves that it refuses them the right way.
408
+ foreach ($systemDir in @('C:\Windows', 'C:\Program Files', 'C:\ProgramData')) {
409
+ $systemRefused = Invoke-Restore -Arguments @('-ProfileDir', $systemDir, '-StateDir', $stateDir) -Label ('the system directory ' + $systemDir)
410
+ Assert-Refused ((T 'drill.assertSystemDirRefused' 'the system directory {0} is refused (exit code + reason)') -f $systemDir) $systemRefused (TA 'drill.keywords.systemDir' @('system directory'))
411
+ Assert-True ((T 'drill.assertSystemDirIntact' 'the system directory {0} is untouched (it was not moved away)') -f $systemDir) (Test-Path -LiteralPath $systemDir -PathType Container)
412
+ }
413
+
414
+ # 4.10 A target profile that does not exist has to be refused, with a
415
+ # reason, and it must NOT be created.
416
+ $missingProfile = Join-Path $tempRoot 'not-there-yet'
417
+ $missingRefused = Invoke-Restore -Arguments @('-ProfileDir', $missingProfile, '-StateDir', $stateDir) -Label 'a target that does not exist'
418
+ Assert-Refused (T 'drill.assertMissingDirRefused' 'a target profile that does not exist is refused (exit code + reason)') $missingRefused (TA 'drill.keywords.missingDir' @('does not exist'))
419
+ Assert-True (T 'drill.assertMissingDirNotCreated' 'the target was not created') (-not (Test-Path -LiteralPath $missingProfile))
420
+
421
+ # 4.11 "the configuration says it is installed but it is not" has to fail:
422
+ # the profile lists the plugin, node_modules does not have it.
423
+ $lyingProfile = Join-Path $tempRoot 'lying-profile'
424
+ $lyingState = Join-Path $tempRoot 'state-lying'
425
+ $lyingBackups = Join-Path $lyingState 'backups'
426
+ $lyingStamp = '2026-10-29T03-00-00-000Z'
427
+ $lyingSnapshot = Join-Path $lyingBackups $lyingStamp
428
+ New-Item -ItemType Directory -Path $lyingProfile -Force | Out-Null
429
+ New-Item -ItemType Directory -Path (Join-Path $lyingSnapshot 'config') -Force | Out-Null
430
+ Write-TextFile (Join-Path $lyingProfile 'package.json') "{`n `"name`": `"lying-profile`",`n `"dsh`": { `"profile`": { `"bundles`": [`"dsh-autorestart`"] } }`n}"
431
+ Copy-Item -LiteralPath (Join-Path $lyingProfile 'package.json') -Destination (Join-Path $lyingSnapshot 'config\package.json') -Force
432
+ $lyingManifest = [ordered]@{
433
+ stamp = $lyingStamp
434
+ at = [int64] 1792000001000
435
+ atLocal = '2026-10-29T09:00:01+08:00'
436
+ profileDir = $lyingProfile
437
+ includePlugins = $false
438
+ files = @('config\package.json')
439
+ bytes = 64
440
+ }
441
+ Write-TextFile (Join-Path $lyingSnapshot 'manifest.json') ($lyingManifest | ConvertTo-Json -Depth 5)
442
+ $lying = Invoke-Restore -Arguments @('-ProfileDir', $lyingProfile, '-StateDir', $lyingState) -Label 'the configuration says it is installed'
443
+ Assert-Refused (T 'drill.assertLyingRefused' 'a profile that lists the plugin without having it must fail (exit code + reason)') $lying (TA 'drill.keywords.notValidated' @('did not pass the check'))
444
+ Assert-True (T 'drill.assertLyingBrokenHint' 'the failed restore says where the broken one is') ($lying.Output -like '*broken*')
445
+
446
+ # 4.12 The encoding rules of the delivered files: the .ps1 is pure ASCII,
447
+ # the .cmd is pure ASCII.
448
+ $ps1Bytes = [System.IO.File]::ReadAllBytes($restoreScript)
449
+ $ps1IsAscii = $true
450
+ foreach ($byte in $ps1Bytes) {
451
+ if ($byte -gt 127) { $ps1IsAscii = $false; break }
452
+ }
453
+ Assert-True (T 'drill.assertPs1Ascii' 'restore-profile.ps1 is pure ASCII (maxByte <= 127)') $ps1IsAscii
454
+ $cmdBytes = [System.IO.File]::ReadAllBytes($cmdScript)
455
+ $cmdIsAscii = $true
456
+ foreach ($byte in $cmdBytes) {
457
+ if ($byte -gt 127) { $cmdIsAscii = $false; break }
458
+ }
459
+ Assert-True (T 'drill.assertCmdAscii' 'restore-profile.cmd is pure ASCII') $cmdIsAscii
460
+
461
+ # 4.13 Everything happened inside the temporary directory (so the real
462
+ # profile of the user never has to be read).
463
+ $insideTemp = $true
464
+ if ($brokenDirs.Count -ge 1) {
465
+ $insideTemp = $brokenDirs[0].FullName.StartsWith($tempRoot, [System.StringComparison]::OrdinalIgnoreCase)
466
+ }
467
+ Assert-True (T 'drill.assertOnlyTemp' 'the drill only worked inside the temporary directory') $insideTemp
468
+
469
+ # 4.14 The directory of this drill holds a lock file (the evidence of the
470
+ # re-entry guard).
471
+ Assert-True (T 'drill.assertLockFile' 'the drill directory holds a lock file') (Test-Path -LiteralPath $lockFile -PathType Leaf)
472
+ } catch {
473
+ Write-Host ((T 'drill.error' 'the drill hit an error: {0}') -f $_.Exception.Message) -ForegroundColor Red
474
+ $script:Failures += 1
475
+ } finally {
476
+ # -------------------------------------------------------------------------
477
+ # The cleanup: ONLY its own absolute path, never a wildcard scan of %TEMP%.
478
+ # -------------------------------------------------------------------------
479
+ if ($script:OwnsTempRoot -and $script:TempRoot -ne '') {
480
+ Say ((T 'drill.cleanup' 'cleaning up the temporary directory: {0}') -f $script:TempRoot) 'DarkGray'
481
+ try {
482
+ Remove-Item -LiteralPath $script:TempRoot -Recurse -Force -ErrorAction Stop
483
+ } catch {
484
+ # A failed cleanup does not fail the drill, but it is reported.
485
+ }
486
+ if (Test-Path -LiteralPath $script:TempRoot) {
487
+ Write-Host ((T 'drill.notCleaned' 'the temporary directory was not cleaned up, please delete it yourself: {0}') -f $script:TempRoot) -ForegroundColor Yellow
488
+ $script:Failures += 1
489
+ } else {
490
+ Say (T 'drill.cleaned' 'the temporary directory is clean.') 'Green'
491
+ }
492
+ }
493
+ }
494
+
495
+ # ---------------------------------------------------------------------------
496
+ # The result
497
+ # ---------------------------------------------------------------------------
498
+
499
+ if ($script:StringsError -ne '') {
500
+ Write-Host ('WARNING: ' + $script:StringsError) -ForegroundColor Yellow
501
+ }
502
+
503
+ if ($script:Failures -gt 0) {
504
+ Write-Host ((T 'drill.failed' 'the drill failed: {0} assertions did not hold.') -f $script:Failures) -ForegroundColor Red
505
+ exit 1
506
+ }
507
+ Say (T 'drill.passed' 'the drill passed: break it on purpose, restore it, check it, clean up - the whole round trip works.') 'Green'
508
+ exit 0