dsh-autorestart 0.7.0 → 0.8.0
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.
- package/README.md +137 -6
- package/client.js +387 -0
- package/cordis.patch.yml +7 -0
- package/docs/README.zh.md +83 -5
- package/docs/platform-matrix.md +92 -0
- package/docs/rescue-drill.md +606 -0
- package/index.js +598 -3
- package/lib/diagnostics.js +8 -0
- package/lib/rescue-paths.js +253 -0
- package/lib/rescue.js +596 -0
- package/lib/scheduler-capability.js +300 -0
- package/lib/watchdog-arbiter.js +117 -0
- package/lib/watchdog-schedule.js +310 -0
- package/lib/watchdog-tier.js +182 -0
- package/lib/watchdog.js +479 -0
- package/locale/en.json +28 -1
- package/locale/zh.json +28 -1
- package/package.json +1 -1
- package/scripts/README.md +65 -0
- package/scripts/drill-rescue.ps1 +508 -0
- package/scripts/emergency-toggle.ps1 +136 -65
- package/scripts/restore-profile.cmd +44 -0
- package/scripts/restore-profile.ps1 +608 -0
- package/scripts/run-tests.ps1 +1 -1
|
@@ -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
|