@trillboards/edge-sdk 0.2.25 → 0.2.36
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/deploy/windows/ci/verify-allsigned-enforcement.ps1 +24 -1
- package/deploy/windows/ci/verify-update-applier.ps1 +415 -64
- package/dist/ChannelProber.d.ts +37 -1
- package/dist/ChannelProber.d.ts.map +1 -1
- package/dist/ChannelProber.js +113 -5
- package/dist/ChannelProber.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/SetupSessionClient.d.ts +2 -0
- package/dist/SetupSessionClient.d.ts.map +1 -1
- package/dist/SetupSessionClient.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/setupMode.d.ts.map +1 -1
- package/dist/setupMode.js +1 -0
- package/dist/setupMode.js.map +1 -1
- package/package.json +1 -1
|
@@ -51,7 +51,25 @@
|
|
|
51
51
|
[CmdletBinding()]
|
|
52
52
|
param(
|
|
53
53
|
# Arm K needs to create a local account. Off-switch for a host that forbids it.
|
|
54
|
-
[switch]$SkipUnelevatedArm
|
|
54
|
+
[switch]$SkipUnelevatedArm,
|
|
55
|
+
# The production proof owns eight minutes. Seconds are injectable only so the
|
|
56
|
+
# Windows deadline probe can prove a blocked certificate child is killed.
|
|
57
|
+
[ValidateRange(1, 480)]
|
|
58
|
+
[int]$DeadlineSeconds = 480,
|
|
59
|
+
[ValidateRange(0, 60)]
|
|
60
|
+
[int]$CertificateMintDelaySeconds = 0,
|
|
61
|
+
[switch]$CertificateDeadlineProbe,
|
|
62
|
+
# Forces Process.WaitForExit to throw on an unstarted process. CI uses this
|
|
63
|
+
# to prove the wait observer fails closed rather than calling a thrown wait a
|
|
64
|
+
# successful exit and falling into a later unbounded wait.
|
|
65
|
+
[switch]$BoundedWaitFailureProbe,
|
|
66
|
+
# The full CI proof receives the one real certificate already minted, signed,
|
|
67
|
+
# and proven by the preceding AllSigned harness. `powershell.exe` children
|
|
68
|
+
# repeatedly wedged while minting on windows-latest even though the same
|
|
69
|
+
# cmdlet succeeds in the workflow host. Keep the killable child below only for
|
|
70
|
+
# the deliberate deadline mutant; do not use that unreliable process shape
|
|
71
|
+
# as the happy-path fixture factory.
|
|
72
|
+
[string]$CertificateFixturePath
|
|
55
73
|
)
|
|
56
74
|
|
|
57
75
|
$ErrorActionPreference = 'Stop'
|
|
@@ -65,10 +83,95 @@ $Root = Join-Path $env:RUNNER_TEMP 'tb-update-applier'
|
|
|
65
83
|
if (-not $env:RUNNER_TEMP) { $Root = Join-Path $env:TEMP 'tb-update-applier' }
|
|
66
84
|
$Www = Join-Path $Root 'www'
|
|
67
85
|
$Certs = New-Object System.Collections.Generic.List[string]
|
|
86
|
+
$CertSubjects = New-Object System.Collections.Generic.List[string]
|
|
68
87
|
$LocalUser = 'tbupd'
|
|
69
88
|
$Failures = 0
|
|
89
|
+
$StartedAt = Get-Date
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
<#
|
|
92
|
+
A SELF-IMPOSED DEADLINE, because the first run of this harness did not fail -
|
|
93
|
+
it ate the whole job. It printed ONE line, ran for 11 minutes, and the JOB hit
|
|
94
|
+
its 15-minute cap, so every other proof in that step lost its budget too and
|
|
95
|
+
the log said only "The operation was canceled" - naming no cause and proving
|
|
96
|
+
nothing. Not one arm had run.
|
|
97
|
+
|
|
98
|
+
A harness that cannot finish must say so ITSELF, at the point it gives up, and
|
|
99
|
+
loudly enough to be actionable. Every phase below also prints its elapsed
|
|
100
|
+
time, because the reason that first failure was undiagnosable was that there
|
|
101
|
+
was no output between "origin listening" and the first arm.
|
|
102
|
+
#>
|
|
103
|
+
$script:DeadlineSeconds = $DeadlineSeconds
|
|
104
|
+
|
|
105
|
+
function Elapsed { ((Get-Date) - $script:StartedAt).TotalSeconds }
|
|
106
|
+
function Say([string]$m) { Write-Host ("[update-applier {0,6:N1}s] {1}" -f (Elapsed), $m) }
|
|
107
|
+
|
|
108
|
+
$script:CurrentPhase = 'startup'
|
|
109
|
+
|
|
110
|
+
function Fail-Deadline([string]$Phase, [string]$When) {
|
|
111
|
+
Write-Host "FAIL - the harness exceeded its $($script:DeadlineSeconds)-second deadline $When`: $Phase"
|
|
112
|
+
Write-Host ' HARNESS BUDGET failure, not a product failure. The arms above ran;'
|
|
113
|
+
Write-Host ' the ones below never started. Read the per-phase timings to find'
|
|
114
|
+
Write-Host ' what got slow before assuming a defect in apply-update.ps1.'
|
|
115
|
+
Cleanup
|
|
116
|
+
exit 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function Assert-WithinDeadline([string]$Phase) {
|
|
120
|
+
$script:CurrentPhase = $Phase
|
|
121
|
+
if ((Elapsed) -lt $script:DeadlineSeconds) { return }
|
|
122
|
+
Fail-Deadline $Phase 'before starting'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function Remaining-DeadlineSeconds {
|
|
126
|
+
[Math]::Max(0, $script:DeadlineSeconds - (Elapsed))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
<#
|
|
130
|
+
Wait for a process, but never past the harness deadline.
|
|
131
|
+
|
|
132
|
+
Assert-WithinDeadline can only sample time BETWEEN phases, so on its own it
|
|
133
|
+
cannot stop a phase that is ALREADY blocked - and that is precisely the
|
|
134
|
+
failure this harness exists to prevent. The first run spent eleven minutes
|
|
135
|
+
inside a single call; the job's 15-minute cap ended it, the harness did not,
|
|
136
|
+
and the log named no cause. A deadline that can only fire between the things
|
|
137
|
+
it is bounding is not a deadline.
|
|
138
|
+
|
|
139
|
+
So every blocking wait goes through here, bounded by the budget REMAINING at
|
|
140
|
+
the moment the wait starts. The deadline therefore stays a whole-harness
|
|
141
|
+
deadline rather than becoming a per-call timeout: eight slow phases cannot
|
|
142
|
+
each consume eight minutes.
|
|
143
|
+
|
|
144
|
+
On expiry we kill the process before reporting, so the cleanup that follows
|
|
145
|
+
is not itself blocked by the thing that hung.
|
|
146
|
+
#>
|
|
147
|
+
function Wait-Bounded {
|
|
148
|
+
param([System.Diagnostics.Process]$Process, [string]$Phase = $script:CurrentPhase)
|
|
149
|
+
$budget = Remaining-DeadlineSeconds
|
|
150
|
+
if ($budget -le 0) {
|
|
151
|
+
try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
|
|
152
|
+
Fail-Deadline $Phase 'before starting'
|
|
153
|
+
}
|
|
154
|
+
$timeoutMilliseconds = [int]([Math]::Min(2147483, $budget) * 1000)
|
|
155
|
+
$exited = $false
|
|
156
|
+
try {
|
|
157
|
+
$exited = $Process.WaitForExit($timeoutMilliseconds)
|
|
158
|
+
} catch {
|
|
159
|
+
$waitException = $_.Exception
|
|
160
|
+
if ($null -ne $waitException.InnerException) { $waitException = $waitException.InnerException }
|
|
161
|
+
$waitErrorType = $waitException.GetType().FullName
|
|
162
|
+
$waitErrorMessage = $waitException.Message
|
|
163
|
+
try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
|
|
164
|
+
try { $Process.WaitForExit(5000) | Out-Null } catch {}
|
|
165
|
+
try { $Process.Close() } catch {}
|
|
166
|
+
Die "$Phase bounded process wait failed ($waitErrorType): $waitErrorMessage"
|
|
167
|
+
}
|
|
168
|
+
if (-not $exited) {
|
|
169
|
+
try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
|
|
170
|
+
try { $Process.WaitForExit(5000) | Out-Null } catch {}
|
|
171
|
+
Fail-Deadline $Phase 'while running'
|
|
172
|
+
}
|
|
173
|
+
return $Process
|
|
174
|
+
}
|
|
72
175
|
function Pass([string]$m) { Write-Host "PASS - $m" }
|
|
73
176
|
function Bad([string]$m) { Write-Host "FAIL - $m"; $script:Failures++ }
|
|
74
177
|
function Die([string]$m) { Write-Host "FAIL - $m"; Cleanup; exit 1 }
|
|
@@ -126,17 +229,111 @@ function Stop-Origin {
|
|
|
126
229
|
# Certificates
|
|
127
230
|
# ---------------------------------------------------------------------------
|
|
128
231
|
function New-HarnessCert([string]$Subject) {
|
|
129
|
-
|
|
130
|
-
|
|
232
|
+
# New-SelfSignedCertificate blocked for longer than the GitHub step budget on
|
|
233
|
+
# 2026-08-15. A synchronous cmdlet cannot be interrupted by Wait-Bounded, so
|
|
234
|
+
# mint in a disposable child process. The child receives the SAME remaining
|
|
235
|
+
# whole-harness budget as every applier invocation and is forcibly killed on
|
|
236
|
+
# expiry; a phase-specific failure is emitted before the job timeout can turn
|
|
237
|
+
# the result into an unattributable cancellation.
|
|
238
|
+
$phase = "minting code-signing certificate '$Subject'"
|
|
239
|
+
$tag = [guid]::NewGuid().ToString('N')
|
|
240
|
+
$thumbprintPath = Join-Path $Root "cert-$tag.thumbprint"
|
|
241
|
+
$stderrPath = Join-Path $Root "cert-$tag.stderr"
|
|
242
|
+
$subjectLiteral = $Subject.Replace("'", "''")
|
|
243
|
+
$thumbprintLiteral = $thumbprintPath.Replace("'", "''")
|
|
244
|
+
$stderrLiteral = $stderrPath.Replace("'", "''")
|
|
245
|
+
$script:CertSubjects.Add("CN=$Subject")
|
|
246
|
+
$mintSource = @"
|
|
247
|
+
`$ErrorActionPreference = 'Stop'
|
|
248
|
+
`$ProgressPreference = 'SilentlyContinue'
|
|
249
|
+
try {
|
|
250
|
+
if ($CertificateMintDelaySeconds -gt 0) { Start-Sleep -Seconds $CertificateMintDelaySeconds }
|
|
251
|
+
`$cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=$subjectLiteral' -CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddDays(2)
|
|
252
|
+
[System.IO.File]::WriteAllText('$thumbprintLiteral', `$cert.Thumbprint)
|
|
253
|
+
} catch {
|
|
254
|
+
[System.IO.File]::WriteAllText('$stderrLiteral', (`$_ | Out-String))
|
|
255
|
+
exit 1
|
|
256
|
+
}
|
|
257
|
+
"@
|
|
258
|
+
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($mintSource))
|
|
259
|
+
$powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
260
|
+
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
|
261
|
+
$startInfo.FileName = $powershell
|
|
262
|
+
$startInfo.Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
|
|
263
|
+
$startInfo.UseShellExecute = $false
|
|
264
|
+
$startInfo.CreateNoWindow = $true
|
|
265
|
+
$p = [Diagnostics.Process]::new()
|
|
266
|
+
$p.StartInfo = $startInfo
|
|
267
|
+
$null = $p.Start()
|
|
268
|
+
$null = Wait-Bounded -Process $p -Phase $phase
|
|
269
|
+
$exitCode = $p.ExitCode
|
|
270
|
+
$p.Close()
|
|
271
|
+
|
|
272
|
+
$childError = ''
|
|
273
|
+
if (Test-Path -LiteralPath $stderrPath) { $childError = (Get-Content -LiteralPath $stderrPath -Raw).Trim() }
|
|
274
|
+
if ($exitCode -ne 0) { Die "$phase failed (exit $exitCode): $childError" }
|
|
275
|
+
if (-not (Test-Path -LiteralPath $thumbprintPath)) { Die "$phase returned no thumbprint" }
|
|
276
|
+
$thumbprint = (Get-Content -LiteralPath $thumbprintPath -Raw).Trim().ToUpperInvariant()
|
|
277
|
+
if ($thumbprint -notmatch '^[0-9A-F]{40,128}$') { Die "$phase returned malformed thumbprint '$thumbprint'" }
|
|
278
|
+
$script:Certs.Add($thumbprint)
|
|
279
|
+
$cert = Get-Item -LiteralPath "Cert:\CurrentUser\My\$thumbprint" -ErrorAction Stop
|
|
280
|
+
if ((-not $cert.HasPrivateKey) -or ($cert.Subject -ne "CN=$Subject")) {
|
|
281
|
+
Die "$phase returned a certificate without the expected subject and private key"
|
|
282
|
+
}
|
|
283
|
+
Remove-Item -LiteralPath $thumbprintPath, $stderrPath -Force -ErrorAction SilentlyContinue
|
|
131
284
|
# Into Root, or the chain does not build and Status is UnknownError - which
|
|
132
285
|
# would make arm E fire for the wrong reason entirely.
|
|
133
286
|
$pub = New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $cert.RawData)
|
|
134
287
|
$store = New-Object Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
|
|
135
288
|
$store.Open('ReadWrite'); $store.Add($pub); $store.Close()
|
|
136
|
-
$Certs.Add($cert.Thumbprint)
|
|
137
289
|
return $cert
|
|
138
290
|
}
|
|
139
291
|
|
|
292
|
+
function Read-HarnessCertificateFixtures([string]$Path) {
|
|
293
|
+
if (-not $Path) { Die 'CertificateFixturePath is required for the full harness' }
|
|
294
|
+
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
295
|
+
Die "CertificateFixturePath does not exist: $Path"
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
$rows = @(Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json)
|
|
299
|
+
} catch {
|
|
300
|
+
Die "CertificateFixturePath is not valid JSON: $($_.Exception.Message)"
|
|
301
|
+
}
|
|
302
|
+
if ($rows.Count -ne 1) { Die "CertificateFixturePath must contain exactly one certificate (got $($rows.Count))" }
|
|
303
|
+
|
|
304
|
+
$expectedSubjects = @('CN=Trillboards CI AllSigned Harness')
|
|
305
|
+
$bySubject = @{}
|
|
306
|
+
foreach ($row in $rows) {
|
|
307
|
+
$keys = @($row.PSObject.Properties.Name | Sort-Object)
|
|
308
|
+
if (($keys.Count -ne 2) -or (($keys -join ',') -ne 'subject,thumbprint')) {
|
|
309
|
+
Die 'CertificateFixturePath rows must contain exactly subject and thumbprint'
|
|
310
|
+
}
|
|
311
|
+
$subject = "$($row.subject)"
|
|
312
|
+
$thumbprint = "$($row.thumbprint)".Trim().ToUpperInvariant()
|
|
313
|
+
if ($expectedSubjects -notcontains $subject) { Die "CertificateFixturePath contains unexpected subject '$subject'" }
|
|
314
|
+
if ($bySubject.ContainsKey($subject)) { Die "CertificateFixturePath repeats subject '$subject'" }
|
|
315
|
+
if ($thumbprint -notmatch '^[0-9A-F]{40,128}$') { Die "CertificateFixturePath contains malformed thumbprint '$thumbprint'" }
|
|
316
|
+
|
|
317
|
+
$cert = Get-Item -LiteralPath "Cert:\CurrentUser\My\$thumbprint" -ErrorAction SilentlyContinue
|
|
318
|
+
if ((-not $cert) -or (-not $cert.HasPrivateKey) -or ($cert.Subject -ne $subject)) {
|
|
319
|
+
Die "CertificateFixturePath certificate '$subject' is absent or lacks its expected private key"
|
|
320
|
+
}
|
|
321
|
+
$root = Get-Item -LiteralPath "Cert:\LocalMachine\Root\$thumbprint" -ErrorAction SilentlyContinue
|
|
322
|
+
if ((-not $root) -or ($root.Subject -ne $subject) -or ($root.Thumbprint -ne $cert.Thumbprint)) {
|
|
323
|
+
Die "CertificateFixturePath certificate '$subject' is not bound to the matching trusted root"
|
|
324
|
+
}
|
|
325
|
+
$script:Certs.Add($thumbprint)
|
|
326
|
+
$script:CertSubjects.Add($subject)
|
|
327
|
+
$bySubject[$subject] = $cert
|
|
328
|
+
}
|
|
329
|
+
foreach ($subject in $expectedSubjects) {
|
|
330
|
+
if (-not $bySubject.ContainsKey($subject)) { Die "CertificateFixturePath is missing subject '$subject'" }
|
|
331
|
+
}
|
|
332
|
+
return [pscustomobject]@{
|
|
333
|
+
Good = $bySubject['CN=Trillboards CI AllSigned Harness']
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
140
337
|
function Remove-HarnessCerts {
|
|
141
338
|
foreach ($tp in $Certs) {
|
|
142
339
|
foreach ($name in @('My', 'Root')) {
|
|
@@ -148,6 +345,18 @@ function Remove-HarnessCerts {
|
|
|
148
345
|
} catch {}
|
|
149
346
|
}
|
|
150
347
|
}
|
|
348
|
+
# If the child was killed after committing the store write but before writing
|
|
349
|
+
# its thumbprint marker, the parent never saw the thumbprint. Exact harness
|
|
350
|
+
# subjects are therefore a second cleanup key; they are intentionally unique
|
|
351
|
+
# to this ephemeral CI proof.
|
|
352
|
+
foreach ($name in @('My', 'Root')) {
|
|
353
|
+
try {
|
|
354
|
+
$store = New-Object Security.Cryptography.X509Certificates.X509Store($name, 'CurrentUser')
|
|
355
|
+
$store.Open('ReadWrite')
|
|
356
|
+
foreach ($c in @($store.Certificates | Where-Object { $script:CertSubjects.Contains($_.Subject) })) { $store.Remove($c) }
|
|
357
|
+
$store.Close()
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
151
360
|
}
|
|
152
361
|
|
|
153
362
|
function Sign-File([string]$Path, $Cert) {
|
|
@@ -158,27 +367,70 @@ function Sign-File([string]$Path, $Cert) {
|
|
|
158
367
|
# ---------------------------------------------------------------------------
|
|
159
368
|
# Fixtures
|
|
160
369
|
# ---------------------------------------------------------------------------
|
|
370
|
+
<#
|
|
371
|
+
BUILT ONCE, REUSED. The first version of this harness rebuilt the whole tree
|
|
372
|
+
per arm: 4 trees x (copy the runner's ~110 MB node.exe + Authenticode-sign 12
|
|
373
|
+
scripts + zip the lot). On windows-latest that produced ONE line of output and
|
|
374
|
+
then nothing for 11 minutes, until the job hit its 15-minute cap and was
|
|
375
|
+
cancelled - so not one arm ever ran, and the step looked hung rather than slow
|
|
376
|
+
because there was no progress output between phases at all.
|
|
377
|
+
|
|
378
|
+
Two fixes, and the second is the one that matters for the next person:
|
|
379
|
+
|
|
380
|
+
1. Sign the shipped script set ONCE into a cache and COPY the signed bytes
|
|
381
|
+
into each tree. Authenticode signing is the expensive part, not the file
|
|
382
|
+
copy, and the signatures are identical across trees by construction.
|
|
383
|
+
2. bin\node.exe is a small system PE re-signed with the harness certificate,
|
|
384
|
+
NOT the real 110 MB node. The applier only needs it to be PRESENT and
|
|
385
|
+
VALID; the two arms that actually EXECUTE node ask for -RealNode. Re-signing
|
|
386
|
+
also removes the Microsoft CRL distribution point, so the applier's
|
|
387
|
+
Get-AuthenticodeSignature calls resolve against a self-signed chain with
|
|
388
|
+
nothing to look up instead of reaching for the network 13 times per stage.
|
|
389
|
+
#>
|
|
390
|
+
$SignedCache = $null
|
|
391
|
+
|
|
392
|
+
function Initialize-SignedCache($SignCert) {
|
|
393
|
+
if ($script:SignedCache) { return }
|
|
394
|
+
$script:SignedCache = Join-Path $Root 'signed-cache'
|
|
395
|
+
New-Item -ItemType Directory -Force -Path $script:SignedCache | Out-Null
|
|
396
|
+
$t0 = Get-Date
|
|
397
|
+
foreach ($f in @(& (Join-Path $Here 'list-shipped-scripts.ps1') -Kind signable -AbsolutePath)) {
|
|
398
|
+
$dest = Join-Path $script:SignedCache (Split-Path -Leaf $f)
|
|
399
|
+
Copy-Item -LiteralPath $f -Destination $dest -Force
|
|
400
|
+
Sign-File $dest $SignCert
|
|
401
|
+
}
|
|
402
|
+
# A small, already-PE stub that becomes bin\node.exe. where.exe is ~40 KB and
|
|
403
|
+
# is a real signed PE, so re-signing it with the harness cert succeeds and the
|
|
404
|
+
# result carries OUR chain rather than Microsoft's.
|
|
405
|
+
$stub = Join-Path $script:SignedCache 'node.exe'
|
|
406
|
+
Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32\where.exe') -Destination $stub -Force
|
|
407
|
+
Sign-File $stub $SignCert
|
|
408
|
+
Say ("signed-script cache built in {0:N1}s" -f ((Get-Date) - $t0).TotalSeconds)
|
|
409
|
+
}
|
|
410
|
+
|
|
161
411
|
<#
|
|
162
412
|
A tree shaped exactly like the shipped connector: bin\ with the real scripts
|
|
163
413
|
(so the applier's "at least 10 signed executables" floor is met by the real
|
|
164
414
|
file set, not a stub), a node.exe, and a cli.js that answers `version` the way
|
|
165
415
|
install-update.ps1 requires.
|
|
166
416
|
#>
|
|
167
|
-
function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript) {
|
|
417
|
+
function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript, [switch]$RealNode) {
|
|
418
|
+
Initialize-SignedCache $SignCert
|
|
419
|
+
$t0 = Get-Date
|
|
168
420
|
if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
|
|
169
421
|
$bin = Join-Path $Dest 'bin'
|
|
170
422
|
$dist = Join-Path $Dest 'lib\edge-sdk\dist'
|
|
171
423
|
New-Item -ItemType Directory -Force -Path $bin, $dist | Out-Null
|
|
172
424
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
425
|
+
Copy-Item -Path (Join-Path $script:SignedCache '*') -Destination $bin -Force
|
|
426
|
+
|
|
176
427
|
if ($LogonOnlyRegisterTask) {
|
|
177
428
|
# A register-task.ps1 that deliberately produces the PRE-headless shape.
|
|
178
429
|
# This is how arm J reproduces, on an elevated runner, the downgrade a
|
|
179
430
|
# non-elevated field caller would suffer: the cause differs, the observable
|
|
180
431
|
# (the task comes back logon-only) is identical, and that observable is what
|
|
181
|
-
# the guard keys on.
|
|
432
|
+
# the guard keys on. Signed, or the payload would be refused by the
|
|
433
|
+
# signature check before it could ever reach install-update.ps1.
|
|
182
434
|
$downgrade = @'
|
|
183
435
|
[CmdletBinding()]
|
|
184
436
|
param([Parameter(Mandatory = $true)][string]$InstallDir, [string]$Mode = "auto-sense")
|
|
@@ -190,29 +442,28 @@ $principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -
|
|
|
190
442
|
Unregister-ScheduledTask -TaskName "TrillboardsEdge" -Confirm:$false -ErrorAction SilentlyContinue
|
|
191
443
|
Register-ScheduledTask -TaskName "TrillboardsEdge" -Action $action -Trigger $trigger -Principal $principal | Out-Null
|
|
192
444
|
'@
|
|
193
|
-
|
|
445
|
+
$rt = Join-Path $bin 'register-task.ps1'
|
|
446
|
+
[System.IO.File]::WriteAllText($rt, $downgrade, (New-Object System.Text.UTF8Encoding($false)))
|
|
447
|
+
Sign-File $rt $SignCert
|
|
194
448
|
}
|
|
195
449
|
if ($WithUnsignedScript) {
|
|
196
450
|
[System.IO.File]::WriteAllText((Join-Path $bin 'rogue.ps1'), "exit 0`r`n", (New-Object System.Text.UTF8Encoding($false)))
|
|
197
451
|
}
|
|
452
|
+
if ($RealNode) {
|
|
453
|
+
# Only the arms that actually RUN the connector need a real interpreter.
|
|
454
|
+
Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
|
|
455
|
+
$nodeSig = Get-AuthenticodeSignature -LiteralPath (Join-Path $bin 'node.exe')
|
|
456
|
+
if ($nodeSig.Status -ne 'Valid') { Sign-File (Join-Path $bin 'node.exe') $SignCert }
|
|
457
|
+
}
|
|
198
458
|
|
|
199
|
-
Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
|
|
200
459
|
[System.IO.File]::WriteAllText(
|
|
201
460
|
(Join-Path $dist 'cli.js'),
|
|
202
461
|
"if (process.argv[2] === 'version') { console.log('@trillboards/edge-sdk v9.9.9'); process.exit(0); }`r`nsetInterval(function(){}, 1000);`r`n",
|
|
203
462
|
(New-Object System.Text.UTF8Encoding($false)))
|
|
204
|
-
|
|
205
|
-
foreach ($f in @(Get-ChildItem -LiteralPath $bin -File | Where-Object { $_.Name -ne 'rogue.ps1' })) {
|
|
206
|
-
if ($f.Extension -in @('.ps1', '.vbs')) { Sign-File $f.FullName $SignCert }
|
|
207
|
-
}
|
|
208
|
-
# The runner's node.exe is the official OpenJS build and already Valid; sign a
|
|
209
|
-
# copy only if it is not, so the floor check measures signatures rather than
|
|
210
|
-
# the runner image.
|
|
211
|
-
$nodeSig = Get-AuthenticodeSignature -LiteralPath (Join-Path $bin 'node.exe')
|
|
212
|
-
if ($nodeSig.Status -ne 'Valid') { Sign-File (Join-Path $bin 'node.exe') $SignCert }
|
|
463
|
+
Say ("payload tree $(Split-Path -Leaf $Dest) built in {0:N1}s (realNode=$RealNode)" -f ((Get-Date) - $t0).TotalSeconds)
|
|
213
464
|
}
|
|
214
465
|
|
|
215
|
-
function New-InstallTree([string]$Dest) {
|
|
466
|
+
function New-InstallTree([string]$Dest, [switch]$RealNode) {
|
|
216
467
|
if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
|
|
217
468
|
$bin = Join-Path $Dest 'bin'
|
|
218
469
|
$dist = Join-Path $Dest 'lib\edge-sdk\dist'
|
|
@@ -220,7 +471,14 @@ function New-InstallTree([string]$Dest) {
|
|
|
220
471
|
foreach ($f in @('stop-existing.ps1', 'register-task.ps1', 'install-update.ps1', 'apply-update.ps1', 'run-connector.ps1', 'trillboards-edge-task.vbs')) {
|
|
221
472
|
Copy-Item -LiteralPath (Join-Path $Deploy $f) -Destination (Join-Path $bin $f) -Force
|
|
222
473
|
}
|
|
223
|
-
|
|
474
|
+
# Same reasoning as the payload tree: only the arms that RUN the connector
|
|
475
|
+
# need a real interpreter, and copying ~110 MB per arm is what blew the job
|
|
476
|
+
# budget the first time this harness ran.
|
|
477
|
+
if ($RealNode) {
|
|
478
|
+
Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
|
|
479
|
+
} elseif ($script:SignedCache -and (Test-Path -LiteralPath (Join-Path $script:SignedCache 'node.exe'))) {
|
|
480
|
+
Copy-Item -LiteralPath (Join-Path $script:SignedCache 'node.exe') -Destination (Join-Path $bin 'node.exe') -Force
|
|
481
|
+
}
|
|
224
482
|
[System.IO.File]::WriteAllText((Join-Path $dist 'cli.js'), "setInterval(function(){}, 1000);`r`n", (New-Object System.Text.UTF8Encoding($false)))
|
|
225
483
|
}
|
|
226
484
|
|
|
@@ -239,10 +497,49 @@ function New-Catalog([string]$Path, [string]$Version, [string]$Sha, [long]$Size)
|
|
|
239
497
|
plus whether staged.json exists, which together are the only observable the
|
|
240
498
|
connector ever acts on.
|
|
241
499
|
#>
|
|
242
|
-
function Invoke-Applier([string]$InstallDir, [
|
|
500
|
+
function Invoke-Applier([string]$InstallDir, [System.Collections.IDictionary]$ApplierParameters) {
|
|
243
501
|
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
244
|
-
|
|
245
|
-
|
|
502
|
+
# Start-Process flattens -ArgumentList into one command-line string without
|
|
503
|
+
# preserving element boundaries. A value such as the real signer
|
|
504
|
+
# "Trillboards CI AllSigned Harness" therefore arrives at apply-update.ps1
|
|
505
|
+
# as four positional arguments. An array splat is also positional, so it
|
|
506
|
+
# cannot reconstruct names such as -ExpectedVersion after deserialization.
|
|
507
|
+
# Carry a named parameter map as JSON inside an encoded child program instead:
|
|
508
|
+
# the only native-process argument is base64, and the child rebuilds a
|
|
509
|
+
# hashtable that PowerShell binds by parameter name without reparsing values.
|
|
510
|
+
$namedParameters = [ordered]@{ InstallDir = [string]$InstallDir }
|
|
511
|
+
foreach ($name in $ApplierParameters.Keys) {
|
|
512
|
+
if ([string]$name -eq 'InstallDir') { Die 'Invoke-Applier parameters must not override InstallDir' }
|
|
513
|
+
$namedParameters[[string]$name] = $ApplierParameters[$name]
|
|
514
|
+
}
|
|
515
|
+
$invocationJson = [ordered]@{
|
|
516
|
+
applier = [string]$Applier
|
|
517
|
+
parameters = $namedParameters
|
|
518
|
+
} | ConvertTo-Json -Compress
|
|
519
|
+
$invocationBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($invocationJson))
|
|
520
|
+
$childSource = @"
|
|
521
|
+
`$ErrorActionPreference = 'Stop'
|
|
522
|
+
`$decodedInvocation = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$invocationBase64'))
|
|
523
|
+
`$invocation = `$decodedInvocation | ConvertFrom-Json
|
|
524
|
+
`$childApplier = [string]`$invocation.applier
|
|
525
|
+
`$childParameters = @{}
|
|
526
|
+
foreach (`$property in @(`$invocation.parameters.PSObject.Properties)) {
|
|
527
|
+
`$childParameters[`$property.Name] = `$property.Value
|
|
528
|
+
}
|
|
529
|
+
& `$childApplier @childParameters
|
|
530
|
+
exit `$LASTEXITCODE
|
|
531
|
+
"@
|
|
532
|
+
$encodedChild = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childSource))
|
|
533
|
+
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
|
534
|
+
$startInfo.FileName = $ps
|
|
535
|
+
$startInfo.Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedChild"
|
|
536
|
+
$startInfo.UseShellExecute = $false
|
|
537
|
+
$p = [Diagnostics.Process]::new()
|
|
538
|
+
$p.StartInfo = $startInfo
|
|
539
|
+
$null = $p.Start()
|
|
540
|
+
$null = Wait-Bounded -Process $p
|
|
541
|
+
$exitCode = $p.ExitCode
|
|
542
|
+
$p.Close()
|
|
246
543
|
$updateDir = Join-Path $InstallDir '.auto-update'
|
|
247
544
|
$outcome = $null
|
|
248
545
|
$outcomePath = Join-Path $updateDir 'outcome.json'
|
|
@@ -259,7 +556,7 @@ function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
|
|
|
259
556
|
if ($null -ne $outcome.detail) { $detail = [string]$outcome.detail }
|
|
260
557
|
}
|
|
261
558
|
return [pscustomobject]@{
|
|
262
|
-
ExitCode = $
|
|
559
|
+
ExitCode = $exitCode
|
|
263
560
|
Outcome = $outcome
|
|
264
561
|
Result = $result
|
|
265
562
|
Detail = $detail
|
|
@@ -269,15 +566,18 @@ function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
|
|
|
269
566
|
}
|
|
270
567
|
|
|
271
568
|
function Publish-Payload([string]$TreeDir, [string]$Name) {
|
|
569
|
+
$t0 = Get-Date
|
|
272
570
|
$zip = Join-Path $Www $Name
|
|
273
571
|
if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
|
|
274
572
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
275
573
|
[System.IO.Compression.ZipFile]::CreateFromDirectory($TreeDir, $zip, [System.IO.Compression.CompressionLevel]::Fastest, $false)
|
|
276
|
-
|
|
574
|
+
$out = [pscustomobject]@{
|
|
277
575
|
Path = $zip
|
|
278
576
|
Bytes = (Get-Item -LiteralPath $zip).Length
|
|
279
577
|
Sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
280
578
|
}
|
|
579
|
+
Say ("zipped $Name : {0:N1} MB in {1:N1}s" -f ($out.Bytes / 1MB), ((Get-Date) - $t0).TotalSeconds)
|
|
580
|
+
return $out
|
|
281
581
|
}
|
|
282
582
|
|
|
283
583
|
function Get-Shape([string]$Name) {
|
|
@@ -316,17 +616,36 @@ try {
|
|
|
316
616
|
if (-not (Test-Path -LiteralPath $Applier)) { Die "apply-update.ps1 not found at $Applier" }
|
|
317
617
|
if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force }
|
|
318
618
|
New-Item -ItemType Directory -Force -Path $Root, $Www | Out-Null
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
$
|
|
323
|
-
|
|
619
|
+
if (($CertificateMintDelaySeconds -gt 0) -and (-not $CertificateDeadlineProbe)) {
|
|
620
|
+
Die 'CertificateMintDelaySeconds is a deadline-probe input, not a full-harness option'
|
|
621
|
+
}
|
|
622
|
+
if ($BoundedWaitFailureProbe) {
|
|
623
|
+
$unstarted = [Diagnostics.Process]::new()
|
|
624
|
+
$null = Wait-Bounded -Process $unstarted -Phase 'bounded wait observer failure probe'
|
|
625
|
+
Die 'bounded wait observer failure probe was reclassified as a process exit'
|
|
626
|
+
}
|
|
627
|
+
if ($CertificateDeadlineProbe) {
|
|
628
|
+
if ($CertificateMintDelaySeconds -le $DeadlineSeconds) {
|
|
629
|
+
Die 'certificate deadline probe requires a mint delay longer than its deadline'
|
|
630
|
+
}
|
|
631
|
+
Say 'deadline probe: starting a deliberately blocked certificate mint'
|
|
632
|
+
$null = New-HarnessCert 'Trillboards CI Deadline Probe'
|
|
633
|
+
Die 'certificate deadline probe was not stopped by the harness deadline'
|
|
634
|
+
}
|
|
635
|
+
Say 'loading the real certificate retained from the AllSigned proof'
|
|
636
|
+
$certificateFixtures = Read-HarnessCertificateFixtures $CertificateFixturePath
|
|
637
|
+
$goodCert = $certificateFixtures.Good
|
|
638
|
+
$signer = 'Trillboards CI AllSigned Harness'
|
|
324
639
|
$version = '9.9.9'
|
|
640
|
+
Say 'certificates ready'
|
|
641
|
+
|
|
642
|
+
Start-Origin
|
|
325
643
|
|
|
326
644
|
$payloadTree = Join-Path $Root 'payload-src'
|
|
327
645
|
New-PayloadTree -Dest $payloadTree -SignCert $goodCert
|
|
328
646
|
$good = Publish-Payload $payloadTree 'payload.zip'
|
|
329
|
-
Say ("payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
|
|
647
|
+
Say ("reference payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
|
|
648
|
+
Assert-WithinDeadline 'building the reference payload'
|
|
330
649
|
|
|
331
650
|
$catalogGood = Join-Path $Www 'catalog-good.ps1'
|
|
332
651
|
New-Catalog $catalogGood $version $good.Sha $good.Bytes
|
|
@@ -336,10 +655,13 @@ try {
|
|
|
336
655
|
|
|
337
656
|
# ---- A: HAPPY PATH. The control for every arm below it -------------------
|
|
338
657
|
Say '--- arm A: happy path (the shared control) ---'
|
|
658
|
+
Assert-WithinDeadline 'arm A: happy path (the shared control)'
|
|
339
659
|
$installA = Join-Path $Root 'install-a'
|
|
340
660
|
New-InstallTree $installA
|
|
341
|
-
$a = Invoke-Applier $installA @
|
|
342
|
-
|
|
661
|
+
$a = Invoke-Applier $installA ([ordered]@{
|
|
662
|
+
Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-good.ps1"
|
|
663
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
664
|
+
})
|
|
343
665
|
if (-not $a.Staged) {
|
|
344
666
|
Die "arm A did not stage (exit $($a.ExitCode), result '$($a.Result)', detail '$($a.Detail)') - every refusal below would be VACUOUS"
|
|
345
667
|
}
|
|
@@ -349,6 +671,7 @@ try {
|
|
|
349
671
|
|
|
350
672
|
# ---- B: WRONG HASH -------------------------------------------------------
|
|
351
673
|
Say '--- arm B: the payload does not match the signed hash ---'
|
|
674
|
+
Assert-WithinDeadline 'arm B: the payload does not match the signed hash'
|
|
352
675
|
$tampered = Join-Path $Www 'payload-tampered.zip'
|
|
353
676
|
Copy-Item -LiteralPath $good.Path -Destination $tampered -Force
|
|
354
677
|
$bytes = [System.IO.File]::ReadAllBytes($tampered)
|
|
@@ -356,8 +679,10 @@ try {
|
|
|
356
679
|
[System.IO.File]::WriteAllBytes($tampered, $bytes)
|
|
357
680
|
$installB = Join-Path $Root 'install-b'
|
|
358
681
|
New-InstallTree $installB
|
|
359
|
-
$b = Invoke-Applier $installB @
|
|
360
|
-
|
|
682
|
+
$b = Invoke-Applier $installB ([ordered]@{
|
|
683
|
+
Stage = $true; UpdateUrl = "$BaseUrl/payload-tampered.zip"; CatalogUrl = "$BaseUrl/catalog-good.ps1"
|
|
684
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
685
|
+
})
|
|
361
686
|
if ($b.Staged) { Bad 'arm B staged a payload whose SHA-256 did not match the signed catalog' }
|
|
362
687
|
elseif ($b.Result -ne 'rejected') { Bad "arm B recorded '$($b.Result)', expected 'rejected'" }
|
|
363
688
|
elseif ($b.Detail -notmatch 'SHA-256 mismatch') { Bad "arm B rejected for the wrong reason: $($b.Detail)" }
|
|
@@ -366,6 +691,7 @@ try {
|
|
|
366
691
|
|
|
367
692
|
# ---- C: SHORT BODY -------------------------------------------------------
|
|
368
693
|
Say '--- arm C: the download is short (what a disk filling mid-download looks like) ---'
|
|
694
|
+
Assert-WithinDeadline 'arm C: the download is short (what a disk filling mid-download looks like)'
|
|
369
695
|
$short = Join-Path $Www 'payload-short.zip'
|
|
370
696
|
# A real byte[] copy, not a PowerShell range slice: `$bytes[0..n]` boxes every
|
|
371
697
|
# element into an Object[], which WriteAllBytes will not take and which is
|
|
@@ -376,8 +702,10 @@ try {
|
|
|
376
702
|
[System.IO.File]::WriteAllBytes($short, $half)
|
|
377
703
|
$installC = Join-Path $Root 'install-c'
|
|
378
704
|
New-InstallTree $installC
|
|
379
|
-
$c = Invoke-Applier $installC @
|
|
380
|
-
|
|
705
|
+
$c = Invoke-Applier $installC ([ordered]@{
|
|
706
|
+
Stage = $true; UpdateUrl = "$BaseUrl/payload-short.zip"; CatalogUrl = "$BaseUrl/catalog-good.ps1"
|
|
707
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
708
|
+
})
|
|
381
709
|
if ($c.Staged) { Bad 'arm C staged a truncated payload' }
|
|
382
710
|
elseif ($c.Detail -notmatch 'size mismatch') { Bad "arm C rejected for the wrong reason: $($c.Detail)" }
|
|
383
711
|
elseif ($c.Archive) { Bad 'arm C left the truncated archive on disk' }
|
|
@@ -385,6 +713,7 @@ try {
|
|
|
385
713
|
|
|
386
714
|
# ---- D: UNSIGNED CATALOG -------------------------------------------------
|
|
387
715
|
Say '--- arm D: the catalog is not signed at all ---'
|
|
716
|
+
Assert-WithinDeadline 'arm D: the catalog is not signed at all'
|
|
388
717
|
$catalogUnsigned = Join-Path $Www 'catalog-unsigned.ps1'
|
|
389
718
|
New-Catalog $catalogUnsigned $version $good.Sha $good.Bytes
|
|
390
719
|
if ((Get-AuthenticodeSignature -LiteralPath $catalogUnsigned).Status -eq 'Valid') {
|
|
@@ -392,31 +721,37 @@ try {
|
|
|
392
721
|
}
|
|
393
722
|
$installD = Join-Path $Root 'install-d'
|
|
394
723
|
New-InstallTree $installD
|
|
395
|
-
$d = Invoke-Applier $installD @
|
|
396
|
-
|
|
724
|
+
$d = Invoke-Applier $installD ([ordered]@{
|
|
725
|
+
Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-unsigned.ps1"
|
|
726
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
727
|
+
})
|
|
397
728
|
if ($d.Staged) { Bad 'arm D staged against an unsigned catalog' }
|
|
398
729
|
elseif ($d.Detail -notmatch 'not Valid') { Bad "arm D rejected for the wrong reason: $($d.Detail)" }
|
|
399
730
|
else { Pass 'an unsigned catalog is rejected' }
|
|
400
731
|
|
|
401
732
|
# ---- E: WRONG PUBLISHER (the sharpest control) ---------------------------
|
|
402
733
|
Say '--- arm E: the catalog is VALIDLY signed, by somebody else ---'
|
|
734
|
+
Assert-WithinDeadline 'arm E: the catalog is VALIDLY signed, by somebody else'
|
|
403
735
|
$catalogOther = Join-Path $Www 'catalog-other.ps1'
|
|
404
736
|
New-Catalog $catalogOther $version $good.Sha $good.Bytes
|
|
405
|
-
Sign-File $catalogOther $
|
|
737
|
+
Sign-File $catalogOther $goodCert
|
|
406
738
|
$otherStatus = (Get-AuthenticodeSignature -LiteralPath $catalogOther).Status
|
|
407
739
|
if ($otherStatus -ne 'Valid') {
|
|
408
740
|
Die "arm E is VACUOUS: the other publisher's signature is '$otherStatus', not Valid - a status check alone would reject it and prove nothing about the subject check"
|
|
409
741
|
}
|
|
410
742
|
$installE = Join-Path $Root 'install-e'
|
|
411
743
|
New-InstallTree $installE
|
|
412
|
-
$e = Invoke-Applier $installE @
|
|
413
|
-
|
|
744
|
+
$e = Invoke-Applier $installE ([ordered]@{
|
|
745
|
+
Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-other.ps1"
|
|
746
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = 'Definitely Not Trillboards'
|
|
747
|
+
})
|
|
414
748
|
if ($e.Staged) { Bad 'arm E staged a catalog signed by an unexpected publisher' }
|
|
415
749
|
elseif ($e.Detail -notmatch 'unexpected publisher') { Bad "arm E rejected for the wrong reason: $($e.Detail)" }
|
|
416
750
|
else { Pass 'a VALID signature from the wrong publisher is rejected on the subject' }
|
|
417
751
|
|
|
418
752
|
# ---- F: SIGNED THEN TAMPERED --------------------------------------------
|
|
419
753
|
Say '--- arm F: the catalog was signed, then edited ---'
|
|
754
|
+
Assert-WithinDeadline 'arm F: the catalog was signed, then edited'
|
|
420
755
|
$catalogEdited = Join-Path $Www 'catalog-edited.ps1'
|
|
421
756
|
Copy-Item -LiteralPath $catalogGood -Destination $catalogEdited -Force
|
|
422
757
|
Add-Content -LiteralPath $catalogEdited -Value '# an edit after signing'
|
|
@@ -424,20 +759,25 @@ try {
|
|
|
424
759
|
if ($editedStatus -eq 'Valid') { Die 'arm F is VACUOUS: the edited catalog still reports Valid' }
|
|
425
760
|
$installF = Join-Path $Root 'install-f'
|
|
426
761
|
New-InstallTree $installF
|
|
427
|
-
$f = Invoke-Applier $installF @
|
|
428
|
-
|
|
762
|
+
$f = Invoke-Applier $installF ([ordered]@{
|
|
763
|
+
Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-edited.ps1"
|
|
764
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
765
|
+
})
|
|
429
766
|
if ($f.Staged) { Bad 'arm F staged a catalog that was edited after signing' }
|
|
430
767
|
else { Pass "a catalog edited after signing is rejected ($editedStatus)" }
|
|
431
768
|
|
|
432
769
|
# ---- G: DISK PREFLIGHT ---------------------------------------------------
|
|
433
770
|
Say '--- arm G: the declared payload does not fit on the volume ---'
|
|
771
|
+
Assert-WithinDeadline 'arm G: the declared payload does not fit on the volume'
|
|
434
772
|
$huge = Join-Path $Www 'catalog-huge.ps1'
|
|
435
773
|
New-Catalog $huge $version $good.Sha 900000000000
|
|
436
774
|
Sign-File $huge $goodCert
|
|
437
775
|
$installG = Join-Path $Root 'install-g'
|
|
438
776
|
New-InstallTree $installG
|
|
439
|
-
$g = Invoke-Applier $installG @
|
|
440
|
-
|
|
777
|
+
$g = Invoke-Applier $installG ([ordered]@{
|
|
778
|
+
Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-huge.ps1"
|
|
779
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
780
|
+
})
|
|
441
781
|
if ($g.Staged) { Bad 'arm G staged a payload that cannot fit' }
|
|
442
782
|
elseif ($g.Detail -notmatch 'free disk space') { Bad "arm G rejected for the wrong reason: $($g.Detail)" }
|
|
443
783
|
elseif ($g.Archive) { Bad 'arm G downloaded before checking that the payload would fit' }
|
|
@@ -445,6 +785,7 @@ try {
|
|
|
445
785
|
|
|
446
786
|
# ---- H: UNSIGNED SCRIPT INSIDE THE PAYLOAD -------------------------------
|
|
447
787
|
Say '--- arm H: the payload contains an unsigned script ---'
|
|
788
|
+
Assert-WithinDeadline 'arm H: the payload contains an unsigned script'
|
|
448
789
|
$rogueTree = Join-Path $Root 'payload-rogue'
|
|
449
790
|
New-PayloadTree -Dest $rogueTree -SignCert $goodCert -WithUnsignedScript
|
|
450
791
|
$rogue = Publish-Payload $rogueTree 'payload-rogue.zip'
|
|
@@ -453,8 +794,10 @@ try {
|
|
|
453
794
|
Sign-File $catalogRogue $goodCert
|
|
454
795
|
$installH = Join-Path $Root 'install-h'
|
|
455
796
|
New-InstallTree $installH
|
|
456
|
-
$h = Invoke-Applier $installH @
|
|
457
|
-
|
|
797
|
+
$h = Invoke-Applier $installH ([ordered]@{
|
|
798
|
+
Stage = $true; UpdateUrl = "$BaseUrl/payload-rogue.zip"; CatalogUrl = "$BaseUrl/catalog-rogue.ps1"
|
|
799
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
800
|
+
})
|
|
458
801
|
if ($h.Staged) { Bad 'arm H staged a payload containing an unsigned script' }
|
|
459
802
|
elseif ($h.Detail -notmatch 'unsigned or invalid') { Bad "arm H rejected for the wrong reason: $($h.Detail)" }
|
|
460
803
|
else { Pass 'a payload carrying an unsigned script is rejected' }
|
|
@@ -468,17 +811,19 @@ try {
|
|
|
468
811
|
)) {
|
|
469
812
|
Say "--- arm $($arm.Name): commit over a RUNNING connector, with a payload that would re-register logon-only ---"
|
|
470
813
|
$installJ = Join-Path $Root "install-$($arm.Name)"
|
|
471
|
-
New-InstallTree $installJ
|
|
814
|
+
New-InstallTree $installJ -RealNode
|
|
472
815
|
$downTree = Join-Path $Root "payload-down-$($arm.Name)"
|
|
473
|
-
New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask
|
|
816
|
+
New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask -RealNode
|
|
474
817
|
$down = Publish-Payload $downTree "payload-down-$($arm.Name).zip"
|
|
475
818
|
$catDown = Join-Path $Www "catalog-down-$($arm.Name).ps1"
|
|
476
819
|
New-Catalog $catDown $version $down.Sha $down.Bytes
|
|
477
820
|
Sign-File $catDown $goodCert
|
|
478
821
|
|
|
479
|
-
$staged = Invoke-Applier $installJ @
|
|
480
|
-
|
|
481
|
-
|
|
822
|
+
$staged = Invoke-Applier $installJ ([ordered]@{
|
|
823
|
+
Stage = $true; UpdateUrl = "$BaseUrl/payload-down-$($arm.Name).zip"
|
|
824
|
+
CatalogUrl = "$BaseUrl/catalog-down-$($arm.Name).ps1"; ExpectedVersion = $version
|
|
825
|
+
CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
826
|
+
})
|
|
482
827
|
if (-not $staged.Staged) { Die "arm $($arm.Name) could not stage: $($staged.Detail)" }
|
|
483
828
|
|
|
484
829
|
Register-HeadlessTask $installJ
|
|
@@ -496,9 +841,9 @@ try {
|
|
|
496
841
|
Die "arm $($arm.Name) is VACUOUS: node.exe was not locked while the connector was running, so a successful swap proves nothing"
|
|
497
842
|
}
|
|
498
843
|
|
|
499
|
-
$
|
|
500
|
-
if ($arm.Skip) { $
|
|
501
|
-
$j = Invoke-Applier $installJ $
|
|
844
|
+
$commitParameters = [ordered]@{ Commit = $true; ExpectedVersion = $version; CurrentVersion = '9.9.8' }
|
|
845
|
+
if ($arm.Skip) { $commitParameters['SkipTaskShapeRestore'] = $true }
|
|
846
|
+
$j = Invoke-Applier $installJ $commitParameters
|
|
502
847
|
try { Stop-Process -Id $running.Id -Force -ErrorAction SilentlyContinue } catch {}
|
|
503
848
|
|
|
504
849
|
$after = Get-Shape $TaskName
|
|
@@ -521,8 +866,10 @@ try {
|
|
|
521
866
|
# ---- K: the would-downgrade preflight, from an UNELEVATED caller ---------
|
|
522
867
|
if ($SkipUnelevatedArm) {
|
|
523
868
|
Say '--- arm K SKIPPED by request ---'
|
|
869
|
+
Assert-WithinDeadline 'arm K SKIPPED by request'
|
|
524
870
|
} else {
|
|
525
871
|
Say '--- arm K: an unelevated caller must REFUSE rather than downgrade ---'
|
|
872
|
+
Assert-WithinDeadline 'arm K: an unelevated caller must REFUSE rather than downgrade'
|
|
526
873
|
$installK = Join-Path $Root 'install-k'
|
|
527
874
|
New-InstallTree $installK
|
|
528
875
|
$downTree = Join-Path $Root 'payload-k'
|
|
@@ -531,8 +878,10 @@ try {
|
|
|
531
878
|
$catK = Join-Path $Www 'catalog-k.ps1'
|
|
532
879
|
New-Catalog $catK $version $down.Sha $down.Bytes
|
|
533
880
|
Sign-File $catK $goodCert
|
|
534
|
-
$stagedK = Invoke-Applier $installK @
|
|
535
|
-
|
|
881
|
+
$stagedK = Invoke-Applier $installK ([ordered]@{
|
|
882
|
+
Stage = $true; UpdateUrl = "$BaseUrl/payload-k.zip"; CatalogUrl = "$BaseUrl/catalog-k.ps1"
|
|
883
|
+
ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
|
|
884
|
+
})
|
|
536
885
|
if (-not $stagedK.Staged) { Die "arm K could not stage: $($stagedK.Detail)" }
|
|
537
886
|
Register-HeadlessTask $installK
|
|
538
887
|
$beforeK = Get-Shape $TaskName
|
|
@@ -563,8 +912,9 @@ try {
|
|
|
563
912
|
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
564
913
|
# Materialise the profile once, so the run under test is not measuring
|
|
565
914
|
# first-logon profile creation.
|
|
566
|
-
Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
|
|
567
|
-
-WorkingDirectory 'C:\' -
|
|
915
|
+
$warm = Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
|
|
916
|
+
-WorkingDirectory 'C:\' -PassThru -ErrorAction Stop
|
|
917
|
+
$null = Wait-Bounded -Process $warm -Phase 'arm K: materialising the local profile'
|
|
568
918
|
|
|
569
919
|
# Drive the COPY inside the install tree, not the one in the repo
|
|
570
920
|
# workspace: the ACL above covers the install tree, and the runner's
|
|
@@ -572,10 +922,11 @@ try {
|
|
|
572
922
|
$applierCopy = Join-Path $installK 'bin\apply-update.ps1'
|
|
573
923
|
$outK = Join-Path $Root 'k-stdout.txt'
|
|
574
924
|
$errK = Join-Path $Root 'k-stderr.txt'
|
|
575
|
-
$
|
|
925
|
+
$pk = Start-Process -FilePath $ps -Credential $cred -LoadUserProfile -WorkingDirectory 'C:\' -PassThru `
|
|
576
926
|
-RedirectStandardOutput $outK -RedirectStandardError $errK `
|
|
577
927
|
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $applierCopy,
|
|
578
928
|
'-InstallDir', $installK, '-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
|
|
929
|
+
$null = Wait-Bounded -Process $pk
|
|
579
930
|
foreach ($f in @($outK, $errK)) {
|
|
580
931
|
if (Test-Path -LiteralPath $f) {
|
|
581
932
|
$t = Get-Content -Raw -LiteralPath $f
|