@trillboards/edge-sdk 0.2.19 → 0.2.21
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/README.md +41 -0
- package/deploy/windows/apply-update.ps1 +530 -0
- package/deploy/windows/build-windows.ps1 +12 -0
- package/deploy/windows/ci/verify-update-applier.ps1 +621 -0
- package/dist/CloudSensingManager.d.ts +10 -0
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +56 -0
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/ConnectorStatusClient.d.ts +104 -0
- package/dist/ConnectorStatusClient.d.ts.map +1 -0
- package/dist/ConnectorStatusClient.js +100 -0
- package/dist/ConnectorStatusClient.js.map +1 -0
- package/dist/EdgeAgent.js +1 -1
- package/dist/autoSense.d.ts +14 -0
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +56 -2
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -0
- package/dist/cli.js.map +1 -1
- package/dist/connectorUpdate.d.ts +369 -0
- package/dist/connectorUpdate.d.ts.map +1 -0
- package/dist/connectorUpdate.js +701 -0
- package/dist/connectorUpdate.js.map +1 -0
- package/dist/connectorUpdateApply.d.ts +121 -0
- package/dist/connectorUpdateApply.d.ts.map +1 -0
- package/dist/connectorUpdateApply.js +237 -0
- package/dist/connectorUpdateApply.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/sensingRunMode.d.ts +11 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +15 -0
- package/dist/sensingRunMode.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
verify-update-applier.ps1 - prove apply-update.ps1 REFUSES every payload it
|
|
4
|
+
should refuse, and accepts the one it should.
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
An updater that installs an unverified payload is a remote-code-execution
|
|
8
|
+
channel into every customer's store, so the assertions that matter here are
|
|
9
|
+
the negative ones. EVERY negative arm is paired with a control that must
|
|
10
|
+
behave differently, because an assertion that would also hold if the code did
|
|
11
|
+
nothing proves nothing.
|
|
12
|
+
|
|
13
|
+
That is not ceremony. Three probes written for this connector in one week
|
|
14
|
+
passed on hardware incapable of observing the property they asserted: a window
|
|
15
|
+
probe on a runner with no desktop, an AllSigned control that was itself
|
|
16
|
+
signed, and a policy test where the policy never applied. Arm A below is the
|
|
17
|
+
shared control for all of them - if the happy path does not stage, every
|
|
18
|
+
refusal is indistinguishable from a broken harness, and this script says so
|
|
19
|
+
and exits non-zero rather than reporting green.
|
|
20
|
+
|
|
21
|
+
Everything is served over a real HTTP listener on 127.0.0.1, so the download,
|
|
22
|
+
the size check and the hash check run against actual wire bytes rather than a
|
|
23
|
+
file copy.
|
|
24
|
+
|
|
25
|
+
WHAT THE ARMS COVER
|
|
26
|
+
|
|
27
|
+
A happy path stages, and the marker names the right version
|
|
28
|
+
B wrong hash rejected; the archive is deleted
|
|
29
|
+
C short body rejected on size (the observable shape of a
|
|
30
|
+
disk that filled mid-download)
|
|
31
|
+
D unsigned catalog rejected
|
|
32
|
+
E wrong publisher rejected - and the certificate is VALID and
|
|
33
|
+
TRUSTED, so this proves the subject check is
|
|
34
|
+
what refuses it, not the status check
|
|
35
|
+
F signed then tampered rejected (HashMismatch)
|
|
36
|
+
G disk preflight rejected BEFORE any payload byte is written
|
|
37
|
+
H unsigned script in payload rejected
|
|
38
|
+
I swap over a running copy succeeds, with the existing lock control
|
|
39
|
+
J task-shape downgrade detected and REPAIRED; the control (-Skip
|
|
40
|
+
TaskShapeRestore) must leave it degraded
|
|
41
|
+
K unelevated caller the would-downgrade preflight refuses
|
|
42
|
+
|
|
43
|
+
WHAT THIS CANNOT PROVE. A hosted runner cannot fill its own system volume, so
|
|
44
|
+
arm C proves the CONSEQUENCE of a disk filling mid-download (a short body is
|
|
45
|
+
rejected and the partial file removed) rather than the condition itself, and
|
|
46
|
+
arm G proves the preflight consults real free space via Get-PSDrive and
|
|
47
|
+
refuses. Neither reproduces ENOSPC inside Invoke-WebRequest.
|
|
48
|
+
|
|
49
|
+
.OUTPUTS exit 0 = every arm passed; exit 1 = a failure, INCLUDING a vacuous arm.
|
|
50
|
+
#>
|
|
51
|
+
[CmdletBinding()]
|
|
52
|
+
param(
|
|
53
|
+
# Arm K needs to create a local account. Off-switch for a host that forbids it.
|
|
54
|
+
[switch]$SkipUnelevatedArm
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
$ErrorActionPreference = 'Stop'
|
|
58
|
+
Set-StrictMode -Version Latest
|
|
59
|
+
|
|
60
|
+
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path # deploy\windows\ci
|
|
61
|
+
$Deploy = (Resolve-Path (Join-Path $Here '..')).Path # deploy\windows
|
|
62
|
+
$Applier = Join-Path $Deploy 'apply-update.ps1'
|
|
63
|
+
$TaskName = 'TrillboardsEdge'
|
|
64
|
+
$Root = Join-Path $env:RUNNER_TEMP 'tb-update-applier'
|
|
65
|
+
if (-not $env:RUNNER_TEMP) { $Root = Join-Path $env:TEMP 'tb-update-applier' }
|
|
66
|
+
$Www = Join-Path $Root 'www'
|
|
67
|
+
$Certs = New-Object System.Collections.Generic.List[string]
|
|
68
|
+
$LocalUser = 'tbupd'
|
|
69
|
+
$Failures = 0
|
|
70
|
+
|
|
71
|
+
function Say([string]$m) { Write-Host "[update-applier] $m" }
|
|
72
|
+
function Pass([string]$m) { Write-Host "PASS - $m" }
|
|
73
|
+
function Bad([string]$m) { Write-Host "FAIL - $m"; $script:Failures++ }
|
|
74
|
+
function Die([string]$m) { Write-Host "FAIL - $m"; Cleanup; exit 1 }
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Local HTTP origin
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
$Listener = $null
|
|
80
|
+
$ServerPs = $null
|
|
81
|
+
$BaseUrl = $null
|
|
82
|
+
|
|
83
|
+
function Start-Origin {
|
|
84
|
+
foreach ($port in 34110..34140) {
|
|
85
|
+
$listener = New-Object System.Net.HttpListener
|
|
86
|
+
$listener.Prefixes.Add("http://127.0.0.1:$port/")
|
|
87
|
+
try { $listener.Start() } catch { continue }
|
|
88
|
+
$script:Listener = $listener
|
|
89
|
+
$script:BaseUrl = "http://127.0.0.1:$port"
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
if (-not $script:Listener) { Die 'could not bind a local HTTP port for the origin' }
|
|
93
|
+
|
|
94
|
+
$script:ServerPs = [powershell]::Create()
|
|
95
|
+
$null = $script:ServerPs.AddScript({
|
|
96
|
+
param($listener, $root)
|
|
97
|
+
while ($listener.IsListening) {
|
|
98
|
+
try { $ctx = $listener.GetContext() } catch { break }
|
|
99
|
+
try {
|
|
100
|
+
$rel = [Uri]::UnescapeDataString($ctx.Request.Url.AbsolutePath).TrimStart('/')
|
|
101
|
+
$file = Join-Path $root $rel
|
|
102
|
+
if (Test-Path -LiteralPath $file) {
|
|
103
|
+
$bytes = [System.IO.File]::ReadAllBytes($file)
|
|
104
|
+
$ctx.Response.StatusCode = 200
|
|
105
|
+
$ctx.Response.ContentLength64 = $bytes.Length
|
|
106
|
+
$ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
|
|
107
|
+
} else {
|
|
108
|
+
$ctx.Response.StatusCode = 404
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
} finally {
|
|
112
|
+
try { $ctx.Response.OutputStream.Close() } catch {}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}).AddArgument($script:Listener).AddArgument($Www)
|
|
116
|
+
$null = $script:ServerPs.BeginInvoke()
|
|
117
|
+
Say "origin listening on $script:BaseUrl"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function Stop-Origin {
|
|
121
|
+
try { if ($script:Listener) { $script:Listener.Stop(); $script:Listener.Close() } } catch {}
|
|
122
|
+
try { if ($script:ServerPs) { $script:ServerPs.Dispose() } } catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Certificates
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
function New-HarnessCert([string]$Subject) {
|
|
129
|
+
$cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=$Subject" `
|
|
130
|
+
-CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddDays(2)
|
|
131
|
+
# Into Root, or the chain does not build and Status is UnknownError - which
|
|
132
|
+
# would make arm E fire for the wrong reason entirely.
|
|
133
|
+
$pub = New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $cert.RawData)
|
|
134
|
+
$store = New-Object Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
|
|
135
|
+
$store.Open('ReadWrite'); $store.Add($pub); $store.Close()
|
|
136
|
+
$Certs.Add($cert.Thumbprint)
|
|
137
|
+
return $cert
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function Remove-HarnessCerts {
|
|
141
|
+
foreach ($tp in $Certs) {
|
|
142
|
+
foreach ($name in @('My', 'Root')) {
|
|
143
|
+
try {
|
|
144
|
+
$store = New-Object Security.Cryptography.X509Certificates.X509Store($name, 'CurrentUser')
|
|
145
|
+
$store.Open('ReadWrite')
|
|
146
|
+
foreach ($c in @($store.Certificates | Where-Object { $_.Thumbprint -eq $tp })) { $store.Remove($c) }
|
|
147
|
+
$store.Close()
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function Sign-File([string]$Path, $Cert) {
|
|
154
|
+
$r = Set-AuthenticodeSignature -FilePath $Path -Certificate $Cert -HashAlgorithm SHA256
|
|
155
|
+
if ($r.Status -ne 'Valid') { Die "harness could not sign $Path (got $($r.Status))" }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Fixtures
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
<#
|
|
162
|
+
A tree shaped exactly like the shipped connector: bin\ with the real scripts
|
|
163
|
+
(so the applier's "at least 10 signed executables" floor is met by the real
|
|
164
|
+
file set, not a stub), a node.exe, and a cli.js that answers `version` the way
|
|
165
|
+
install-update.ps1 requires.
|
|
166
|
+
#>
|
|
167
|
+
function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript) {
|
|
168
|
+
if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
|
|
169
|
+
$bin = Join-Path $Dest 'bin'
|
|
170
|
+
$dist = Join-Path $Dest 'lib\edge-sdk\dist'
|
|
171
|
+
New-Item -ItemType Directory -Force -Path $bin, $dist | Out-Null
|
|
172
|
+
|
|
173
|
+
foreach ($f in @(& (Join-Path $Here 'list-shipped-scripts.ps1') -Kind signable -AbsolutePath)) {
|
|
174
|
+
Copy-Item -LiteralPath $f -Destination (Join-Path $bin (Split-Path -Leaf $f)) -Force
|
|
175
|
+
}
|
|
176
|
+
if ($LogonOnlyRegisterTask) {
|
|
177
|
+
# A register-task.ps1 that deliberately produces the PRE-headless shape.
|
|
178
|
+
# This is how arm J reproduces, on an elevated runner, the downgrade a
|
|
179
|
+
# non-elevated field caller would suffer: the cause differs, the observable
|
|
180
|
+
# (the task comes back logon-only) is identical, and that observable is what
|
|
181
|
+
# the guard keys on.
|
|
182
|
+
$downgrade = @'
|
|
183
|
+
[CmdletBinding()]
|
|
184
|
+
param([Parameter(Mandatory = $true)][string]$InstallDir, [string]$Mode = "auto-sense")
|
|
185
|
+
$ErrorActionPreference = "Stop"
|
|
186
|
+
$UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
187
|
+
$action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR "System32\cmd.exe") -Argument "/c exit"
|
|
188
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
|
|
189
|
+
$principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited
|
|
190
|
+
Unregister-ScheduledTask -TaskName "TrillboardsEdge" -Confirm:$false -ErrorAction SilentlyContinue
|
|
191
|
+
Register-ScheduledTask -TaskName "TrillboardsEdge" -Action $action -Trigger $trigger -Principal $principal | Out-Null
|
|
192
|
+
'@
|
|
193
|
+
[System.IO.File]::WriteAllText((Join-Path $bin 'register-task.ps1'), $downgrade, (New-Object System.Text.UTF8Encoding($false)))
|
|
194
|
+
}
|
|
195
|
+
if ($WithUnsignedScript) {
|
|
196
|
+
[System.IO.File]::WriteAllText((Join-Path $bin 'rogue.ps1'), "exit 0`r`n", (New-Object System.Text.UTF8Encoding($false)))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
|
|
200
|
+
[System.IO.File]::WriteAllText(
|
|
201
|
+
(Join-Path $dist 'cli.js'),
|
|
202
|
+
"if (process.argv[2] === 'version') { console.log('@trillboards/edge-sdk v9.9.9'); process.exit(0); }`r`nsetInterval(function(){}, 1000);`r`n",
|
|
203
|
+
(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 }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function New-InstallTree([string]$Dest) {
|
|
216
|
+
if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
|
|
217
|
+
$bin = Join-Path $Dest 'bin'
|
|
218
|
+
$dist = Join-Path $Dest 'lib\edge-sdk\dist'
|
|
219
|
+
New-Item -ItemType Directory -Force -Path $bin, $dist, (Join-Path $Dest 'logs') | Out-Null
|
|
220
|
+
foreach ($f in @('stop-existing.ps1', 'register-task.ps1', 'install-update.ps1', 'apply-update.ps1', 'run-connector.ps1', 'trillboards-edge-task.vbs')) {
|
|
221
|
+
Copy-Item -LiteralPath (Join-Path $Deploy $f) -Destination (Join-Path $bin $f) -Force
|
|
222
|
+
}
|
|
223
|
+
Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
|
|
224
|
+
[System.IO.File]::WriteAllText((Join-Path $dist 'cli.js'), "setInterval(function(){}, 1000);`r`n", (New-Object System.Text.UTF8Encoding($false)))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function New-Catalog([string]$Path, [string]$Version, [string]$Sha, [long]$Size) {
|
|
228
|
+
$lines = @(
|
|
229
|
+
'# Trillboards connector update catalog - harness fixture.',
|
|
230
|
+
("`$TrillboardsUpdateVersion = '" + $Version + "'"),
|
|
231
|
+
("`$TrillboardsUpdateSha256 = '" + $Sha + "'"),
|
|
232
|
+
("`$TrillboardsUpdateSizeBytes = " + $Size)
|
|
233
|
+
)
|
|
234
|
+
[System.IO.File]::WriteAllText($Path, (($lines -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false)))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
<#
|
|
238
|
+
Run the applier and report what it concluded. Returns the parsed outcome.json
|
|
239
|
+
plus whether staged.json exists, which together are the only observable the
|
|
240
|
+
connector ever acts on.
|
|
241
|
+
#>
|
|
242
|
+
function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
|
|
243
|
+
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
244
|
+
$all = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $Applier, '-InstallDir', $InstallDir) + $Args
|
|
245
|
+
$p = Start-Process -FilePath $ps -ArgumentList $all -NoNewWindow -PassThru -Wait
|
|
246
|
+
$updateDir = Join-Path $InstallDir '.auto-update'
|
|
247
|
+
$outcome = $null
|
|
248
|
+
$outcomePath = Join-Path $updateDir 'outcome.json'
|
|
249
|
+
if (Test-Path -LiteralPath $outcomePath) {
|
|
250
|
+
try { $outcome = Get-Content -LiteralPath $outcomePath -Raw | ConvertFrom-Json } catch {}
|
|
251
|
+
}
|
|
252
|
+
# Precomputed rather than inlined into the hashtable: an `if` in a hashtable
|
|
253
|
+
# VALUE position is a statement, and Set-StrictMode makes the property reads
|
|
254
|
+
# inside it throw when $outcome is absent.
|
|
255
|
+
$result = ''
|
|
256
|
+
$detail = ''
|
|
257
|
+
if ($null -ne $outcome) {
|
|
258
|
+
$result = [string]$outcome.result
|
|
259
|
+
if ($null -ne $outcome.detail) { $detail = [string]$outcome.detail }
|
|
260
|
+
}
|
|
261
|
+
return [pscustomobject]@{
|
|
262
|
+
ExitCode = $p.ExitCode
|
|
263
|
+
Outcome = $outcome
|
|
264
|
+
Result = $result
|
|
265
|
+
Detail = $detail
|
|
266
|
+
Staged = (Test-Path -LiteralPath (Join-Path $updateDir 'staged.json'))
|
|
267
|
+
Archive = (Test-Path -LiteralPath (Join-Path $updateDir 'payload.zip'))
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function Publish-Payload([string]$TreeDir, [string]$Name) {
|
|
272
|
+
$zip = Join-Path $Www $Name
|
|
273
|
+
if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
|
|
274
|
+
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
275
|
+
[System.IO.Compression.ZipFile]::CreateFromDirectory($TreeDir, $zip, [System.IO.Compression.CompressionLevel]::Fastest, $false)
|
|
276
|
+
return [pscustomobject]@{
|
|
277
|
+
Path = $zip
|
|
278
|
+
Bytes = (Get-Item -LiteralPath $zip).Length
|
|
279
|
+
Sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function Get-Shape([string]$Name) {
|
|
284
|
+
$t = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
285
|
+
if (-not $t) { return $null }
|
|
286
|
+
$xml = Export-ScheduledTask -TaskName $Name
|
|
287
|
+
$logon = 'unknown'
|
|
288
|
+
$m = [regex]::Match($xml, '<LogonType>([^<]+)</LogonType>')
|
|
289
|
+
if ($m.Success) { $logon = $m.Groups[1].Value }
|
|
290
|
+
$free = ($xml -match '<BootTrigger>') -or ($xml -match '<Repetition>')
|
|
291
|
+
return [pscustomobject]@{ LogonType = $logon; Headless = ($free -and ($logon -ne 'InteractiveToken')) }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function Register-HeadlessTask([string]$InstallDir) {
|
|
295
|
+
$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
296
|
+
$action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\cmd.exe') -Argument '/c exit'
|
|
297
|
+
# A repetition trigger far in the future: logon-free by shape, and it cannot
|
|
298
|
+
# fire during the run and start anything.
|
|
299
|
+
$trigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddDays(3650)) `
|
|
300
|
+
-RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
|
|
301
|
+
$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType S4U -RunLevel Limited
|
|
302
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
303
|
+
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal | Out-Null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function Cleanup {
|
|
307
|
+
Stop-Origin
|
|
308
|
+
Remove-HarnessCerts
|
|
309
|
+
try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
|
|
310
|
+
try { Unregister-ScheduledTask -TaskName 'TrillboardsEdgeUpdateProbe' -Confirm:$false -ErrorAction SilentlyContinue } catch {}
|
|
311
|
+
try { Remove-LocalUser -Name $LocalUser -ErrorAction SilentlyContinue } catch {}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
try {
|
|
316
|
+
if (-not (Test-Path -LiteralPath $Applier)) { Die "apply-update.ps1 not found at $Applier" }
|
|
317
|
+
if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force }
|
|
318
|
+
New-Item -ItemType Directory -Force -Path $Root, $Www | Out-Null
|
|
319
|
+
Start-Origin
|
|
320
|
+
|
|
321
|
+
$goodCert = New-HarnessCert 'Trillboards CI Update Harness'
|
|
322
|
+
$otherCert = New-HarnessCert 'Definitely Not Trillboards'
|
|
323
|
+
$signer = 'Trillboards CI Update Harness'
|
|
324
|
+
$version = '9.9.9'
|
|
325
|
+
|
|
326
|
+
$payloadTree = Join-Path $Root 'payload-src'
|
|
327
|
+
New-PayloadTree -Dest $payloadTree -SignCert $goodCert
|
|
328
|
+
$good = Publish-Payload $payloadTree 'payload.zip'
|
|
329
|
+
Say ("payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
|
|
330
|
+
|
|
331
|
+
$catalogGood = Join-Path $Www 'catalog-good.ps1'
|
|
332
|
+
New-Catalog $catalogGood $version $good.Sha $good.Bytes
|
|
333
|
+
Sign-File $catalogGood $goodCert
|
|
334
|
+
|
|
335
|
+
$urlZip = "$BaseUrl/payload.zip"
|
|
336
|
+
|
|
337
|
+
# ---- A: HAPPY PATH. The control for every arm below it -------------------
|
|
338
|
+
Say '--- arm A: happy path (the shared control) ---'
|
|
339
|
+
$installA = Join-Path $Root 'install-a'
|
|
340
|
+
New-InstallTree $installA
|
|
341
|
+
$a = Invoke-Applier $installA @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
|
|
342
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
343
|
+
if (-not $a.Staged) {
|
|
344
|
+
Die "arm A did not stage (exit $($a.ExitCode), result '$($a.Result)', detail '$($a.Detail)') - every refusal below would be VACUOUS"
|
|
345
|
+
}
|
|
346
|
+
$stagedVersion = (Get-Content -LiteralPath (Join-Path $installA '.auto-update\staged.json') -Raw | ConvertFrom-Json).version
|
|
347
|
+
if ($stagedVersion -ne $version) { Bad "arm A staged version '$stagedVersion', expected '$version'" }
|
|
348
|
+
else { Pass 'a correct, signed payload stages, and the marker names the staged version' }
|
|
349
|
+
|
|
350
|
+
# ---- B: WRONG HASH -------------------------------------------------------
|
|
351
|
+
Say '--- arm B: the payload does not match the signed hash ---'
|
|
352
|
+
$tampered = Join-Path $Www 'payload-tampered.zip'
|
|
353
|
+
Copy-Item -LiteralPath $good.Path -Destination $tampered -Force
|
|
354
|
+
$bytes = [System.IO.File]::ReadAllBytes($tampered)
|
|
355
|
+
$bytes[$bytes.Length - 1] = [byte](($bytes[$bytes.Length - 1] + 1) % 256)
|
|
356
|
+
[System.IO.File]::WriteAllBytes($tampered, $bytes)
|
|
357
|
+
$installB = Join-Path $Root 'install-b'
|
|
358
|
+
New-InstallTree $installB
|
|
359
|
+
$b = Invoke-Applier $installB @('-Stage', '-UpdateUrl', "$BaseUrl/payload-tampered.zip", '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
|
|
360
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
361
|
+
if ($b.Staged) { Bad 'arm B staged a payload whose SHA-256 did not match the signed catalog' }
|
|
362
|
+
elseif ($b.Result -ne 'rejected') { Bad "arm B recorded '$($b.Result)', expected 'rejected'" }
|
|
363
|
+
elseif ($b.Detail -notmatch 'SHA-256 mismatch') { Bad "arm B rejected for the wrong reason: $($b.Detail)" }
|
|
364
|
+
elseif ($b.Archive) { Bad 'arm B left the rejected archive on disk' }
|
|
365
|
+
else { Pass 'a payload that fails the signed hash is rejected and deleted' }
|
|
366
|
+
|
|
367
|
+
# ---- C: SHORT BODY -------------------------------------------------------
|
|
368
|
+
Say '--- arm C: the download is short (what a disk filling mid-download looks like) ---'
|
|
369
|
+
$short = Join-Path $Www 'payload-short.zip'
|
|
370
|
+
# A real byte[] copy, not a PowerShell range slice: `$bytes[0..n]` boxes every
|
|
371
|
+
# element into an Object[], which WriteAllBytes will not take and which is
|
|
372
|
+
# pathologically slow at payload scale.
|
|
373
|
+
$full = [System.IO.File]::ReadAllBytes($good.Path)
|
|
374
|
+
$half = New-Object byte[] ([int]($good.Bytes / 2))
|
|
375
|
+
[System.Array]::Copy($full, 0, $half, 0, $half.Length)
|
|
376
|
+
[System.IO.File]::WriteAllBytes($short, $half)
|
|
377
|
+
$installC = Join-Path $Root 'install-c'
|
|
378
|
+
New-InstallTree $installC
|
|
379
|
+
$c = Invoke-Applier $installC @('-Stage', '-UpdateUrl', "$BaseUrl/payload-short.zip", '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
|
|
380
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
381
|
+
if ($c.Staged) { Bad 'arm C staged a truncated payload' }
|
|
382
|
+
elseif ($c.Detail -notmatch 'size mismatch') { Bad "arm C rejected for the wrong reason: $($c.Detail)" }
|
|
383
|
+
elseif ($c.Archive) { Bad 'arm C left the truncated archive on disk' }
|
|
384
|
+
else { Pass 'a short download is rejected on size and the partial file is removed' }
|
|
385
|
+
|
|
386
|
+
# ---- D: UNSIGNED CATALOG -------------------------------------------------
|
|
387
|
+
Say '--- arm D: the catalog is not signed at all ---'
|
|
388
|
+
$catalogUnsigned = Join-Path $Www 'catalog-unsigned.ps1'
|
|
389
|
+
New-Catalog $catalogUnsigned $version $good.Sha $good.Bytes
|
|
390
|
+
if ((Get-AuthenticodeSignature -LiteralPath $catalogUnsigned).Status -eq 'Valid') {
|
|
391
|
+
Die 'arm D is VACUOUS: the "unsigned" catalog reports a Valid signature'
|
|
392
|
+
}
|
|
393
|
+
$installD = Join-Path $Root 'install-d'
|
|
394
|
+
New-InstallTree $installD
|
|
395
|
+
$d = Invoke-Applier $installD @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-unsigned.ps1",
|
|
396
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
397
|
+
if ($d.Staged) { Bad 'arm D staged against an unsigned catalog' }
|
|
398
|
+
elseif ($d.Detail -notmatch 'not Valid') { Bad "arm D rejected for the wrong reason: $($d.Detail)" }
|
|
399
|
+
else { Pass 'an unsigned catalog is rejected' }
|
|
400
|
+
|
|
401
|
+
# ---- E: WRONG PUBLISHER (the sharpest control) ---------------------------
|
|
402
|
+
Say '--- arm E: the catalog is VALIDLY signed, by somebody else ---'
|
|
403
|
+
$catalogOther = Join-Path $Www 'catalog-other.ps1'
|
|
404
|
+
New-Catalog $catalogOther $version $good.Sha $good.Bytes
|
|
405
|
+
Sign-File $catalogOther $otherCert
|
|
406
|
+
$otherStatus = (Get-AuthenticodeSignature -LiteralPath $catalogOther).Status
|
|
407
|
+
if ($otherStatus -ne 'Valid') {
|
|
408
|
+
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
|
+
}
|
|
410
|
+
$installE = Join-Path $Root 'install-e'
|
|
411
|
+
New-InstallTree $installE
|
|
412
|
+
$e = Invoke-Applier $installE @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-other.ps1",
|
|
413
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
414
|
+
if ($e.Staged) { Bad 'arm E staged a catalog signed by an unexpected publisher' }
|
|
415
|
+
elseif ($e.Detail -notmatch 'unexpected publisher') { Bad "arm E rejected for the wrong reason: $($e.Detail)" }
|
|
416
|
+
else { Pass 'a VALID signature from the wrong publisher is rejected on the subject' }
|
|
417
|
+
|
|
418
|
+
# ---- F: SIGNED THEN TAMPERED --------------------------------------------
|
|
419
|
+
Say '--- arm F: the catalog was signed, then edited ---'
|
|
420
|
+
$catalogEdited = Join-Path $Www 'catalog-edited.ps1'
|
|
421
|
+
Copy-Item -LiteralPath $catalogGood -Destination $catalogEdited -Force
|
|
422
|
+
Add-Content -LiteralPath $catalogEdited -Value '# an edit after signing'
|
|
423
|
+
$editedStatus = (Get-AuthenticodeSignature -LiteralPath $catalogEdited).Status
|
|
424
|
+
if ($editedStatus -eq 'Valid') { Die 'arm F is VACUOUS: the edited catalog still reports Valid' }
|
|
425
|
+
$installF = Join-Path $Root 'install-f'
|
|
426
|
+
New-InstallTree $installF
|
|
427
|
+
$f = Invoke-Applier $installF @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-edited.ps1",
|
|
428
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
429
|
+
if ($f.Staged) { Bad 'arm F staged a catalog that was edited after signing' }
|
|
430
|
+
else { Pass "a catalog edited after signing is rejected ($editedStatus)" }
|
|
431
|
+
|
|
432
|
+
# ---- G: DISK PREFLIGHT ---------------------------------------------------
|
|
433
|
+
Say '--- arm G: the declared payload does not fit on the volume ---'
|
|
434
|
+
$huge = Join-Path $Www 'catalog-huge.ps1'
|
|
435
|
+
New-Catalog $huge $version $good.Sha 900000000000
|
|
436
|
+
Sign-File $huge $goodCert
|
|
437
|
+
$installG = Join-Path $Root 'install-g'
|
|
438
|
+
New-InstallTree $installG
|
|
439
|
+
$g = Invoke-Applier $installG @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-huge.ps1",
|
|
440
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
441
|
+
if ($g.Staged) { Bad 'arm G staged a payload that cannot fit' }
|
|
442
|
+
elseif ($g.Detail -notmatch 'free disk space') { Bad "arm G rejected for the wrong reason: $($g.Detail)" }
|
|
443
|
+
elseif ($g.Archive) { Bad 'arm G downloaded before checking that the payload would fit' }
|
|
444
|
+
else { Pass 'the disk preflight consults real free space and refuses before downloading' }
|
|
445
|
+
|
|
446
|
+
# ---- H: UNSIGNED SCRIPT INSIDE THE PAYLOAD -------------------------------
|
|
447
|
+
Say '--- arm H: the payload contains an unsigned script ---'
|
|
448
|
+
$rogueTree = Join-Path $Root 'payload-rogue'
|
|
449
|
+
New-PayloadTree -Dest $rogueTree -SignCert $goodCert -WithUnsignedScript
|
|
450
|
+
$rogue = Publish-Payload $rogueTree 'payload-rogue.zip'
|
|
451
|
+
$catalogRogue = Join-Path $Www 'catalog-rogue.ps1'
|
|
452
|
+
New-Catalog $catalogRogue $version $rogue.Sha $rogue.Bytes
|
|
453
|
+
Sign-File $catalogRogue $goodCert
|
|
454
|
+
$installH = Join-Path $Root 'install-h'
|
|
455
|
+
New-InstallTree $installH
|
|
456
|
+
$h = Invoke-Applier $installH @('-Stage', '-UpdateUrl', "$BaseUrl/payload-rogue.zip", '-CatalogUrl', "$BaseUrl/catalog-rogue.ps1",
|
|
457
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
458
|
+
if ($h.Staged) { Bad 'arm H staged a payload containing an unsigned script' }
|
|
459
|
+
elseif ($h.Detail -notmatch 'unsigned or invalid') { Bad "arm H rejected for the wrong reason: $($h.Detail)" }
|
|
460
|
+
else { Pass 'a payload carrying an unsigned script is rejected' }
|
|
461
|
+
|
|
462
|
+
# ---- I + J: COMMIT over a running connector, and the shape guard ---------
|
|
463
|
+
# Both use the same fixture, because the shape guard only means anything on a
|
|
464
|
+
# commit that actually swapped something.
|
|
465
|
+
foreach ($arm in @(
|
|
466
|
+
@{ Name = 'J-control'; Skip = $true; ExpectHeadless = $false; ExpectResult = 'failed' },
|
|
467
|
+
@{ Name = 'J'; Skip = $false; ExpectHeadless = $true; ExpectResult = 'success' }
|
|
468
|
+
)) {
|
|
469
|
+
Say "--- arm $($arm.Name): commit over a RUNNING connector, with a payload that would re-register logon-only ---"
|
|
470
|
+
$installJ = Join-Path $Root "install-$($arm.Name)"
|
|
471
|
+
New-InstallTree $installJ
|
|
472
|
+
$downTree = Join-Path $Root "payload-down-$($arm.Name)"
|
|
473
|
+
New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask
|
|
474
|
+
$down = Publish-Payload $downTree "payload-down-$($arm.Name).zip"
|
|
475
|
+
$catDown = Join-Path $Www "catalog-down-$($arm.Name).ps1"
|
|
476
|
+
New-Catalog $catDown $version $down.Sha $down.Bytes
|
|
477
|
+
Sign-File $catDown $goodCert
|
|
478
|
+
|
|
479
|
+
$staged = Invoke-Applier $installJ @('-Stage', '-UpdateUrl', "$BaseUrl/payload-down-$($arm.Name).zip",
|
|
480
|
+
'-CatalogUrl', "$BaseUrl/catalog-down-$($arm.Name).ps1", '-ExpectedVersion', $version,
|
|
481
|
+
'-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
482
|
+
if (-not $staged.Staged) { Die "arm $($arm.Name) could not stage: $($staged.Detail)" }
|
|
483
|
+
|
|
484
|
+
Register-HeadlessTask $installJ
|
|
485
|
+
$before = Get-Shape $TaskName
|
|
486
|
+
if (-not $before.Headless) { Die "arm $($arm.Name) is VACUOUS: the fixture task is not headless to begin with" }
|
|
487
|
+
|
|
488
|
+
# A live connector holding bin\node.exe, exactly as in the field.
|
|
489
|
+
$node = Join-Path $installJ 'bin\node.exe'
|
|
490
|
+
$running = Start-Process -FilePath $node -ArgumentList (Join-Path $installJ 'lib\edge-sdk\dist\cli.js') -PassThru -WindowStyle Hidden
|
|
491
|
+
Start-Sleep -Seconds 3
|
|
492
|
+
$lockedBefore = $false
|
|
493
|
+
try { $s = [IO.File]::Open($node, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $s.Dispose() }
|
|
494
|
+
catch { $lockedBefore = $true }
|
|
495
|
+
if (-not $lockedBefore) {
|
|
496
|
+
Die "arm $($arm.Name) is VACUOUS: node.exe was not locked while the connector was running, so a successful swap proves nothing"
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
$args = @('-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
|
|
500
|
+
if ($arm.Skip) { $args += '-SkipTaskShapeRestore' }
|
|
501
|
+
$j = Invoke-Applier $installJ $args
|
|
502
|
+
try { Stop-Process -Id $running.Id -Force -ErrorAction SilentlyContinue } catch {}
|
|
503
|
+
|
|
504
|
+
$after = Get-Shape $TaskName
|
|
505
|
+
$isHeadless = ($null -ne $after) -and $after.Headless
|
|
506
|
+
$afterLogon = 'absent'
|
|
507
|
+
if ($null -ne $after) { $afterLogon = $after.LogonType }
|
|
508
|
+
if ($isHeadless -ne $arm.ExpectHeadless) {
|
|
509
|
+
Bad "arm $($arm.Name): task headless=$isHeadless, expected $($arm.ExpectHeadless) (logonType $afterLogon)"
|
|
510
|
+
} elseif ($j.Result -ne $arm.ExpectResult) {
|
|
511
|
+
Bad "arm $($arm.Name): recorded '$($j.Result)', expected '$($arm.ExpectResult)' (detail '$($j.Detail)')"
|
|
512
|
+
} elseif (-not $arm.Skip -and -not (Test-Path -LiteralPath (Join-Path $installJ 'lib\edge-sdk\dist\cli.js'))) {
|
|
513
|
+
Bad "arm $($arm.Name): the swap did not land the new lib tree"
|
|
514
|
+
} else {
|
|
515
|
+
if ($arm.Skip) { Pass 'CONTROL: with the shape guard disabled the task IS left logon-only, and that is recorded as failed' }
|
|
516
|
+
else { Pass 'the swap lands over a running connector, and a task left logon-only is detected and repaired' }
|
|
517
|
+
}
|
|
518
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
# ---- K: the would-downgrade preflight, from an UNELEVATED caller ---------
|
|
522
|
+
if ($SkipUnelevatedArm) {
|
|
523
|
+
Say '--- arm K SKIPPED by request ---'
|
|
524
|
+
} else {
|
|
525
|
+
Say '--- arm K: an unelevated caller must REFUSE rather than downgrade ---'
|
|
526
|
+
$installK = Join-Path $Root 'install-k'
|
|
527
|
+
New-InstallTree $installK
|
|
528
|
+
$downTree = Join-Path $Root 'payload-k'
|
|
529
|
+
New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask
|
|
530
|
+
$down = Publish-Payload $downTree 'payload-k.zip'
|
|
531
|
+
$catK = Join-Path $Www 'catalog-k.ps1'
|
|
532
|
+
New-Catalog $catK $version $down.Sha $down.Bytes
|
|
533
|
+
Sign-File $catK $goodCert
|
|
534
|
+
$stagedK = Invoke-Applier $installK @('-Stage', '-UpdateUrl', "$BaseUrl/payload-k.zip", '-CatalogUrl', "$BaseUrl/catalog-k.ps1",
|
|
535
|
+
'-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
|
|
536
|
+
if (-not $stagedK.Staged) { Die "arm K could not stage: $($stagedK.Detail)" }
|
|
537
|
+
Register-HeadlessTask $installK
|
|
538
|
+
$beforeK = Get-Shape $TaskName
|
|
539
|
+
if (($null -eq $beforeK) -or (-not $beforeK.Headless)) {
|
|
540
|
+
Die 'arm K is VACUOUS: the fixture task is not headless'
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
# The technique is lifted from e2e/test-headless-principal.ps1, which
|
|
544
|
+
# measured it working on this runner image (run 31836760844): a STANDARD
|
|
545
|
+
# local account, `Start-Process -Credential -LoadUserProfile`, and icacls
|
|
546
|
+
# rather than Set-Acl. Each of those details is load-bearing -- without
|
|
547
|
+
# -LoadUserProfile the child has no %USERPROFILE%, and without a
|
|
548
|
+
# pre-materialised profile the first run measures profile creation rather
|
|
549
|
+
# than the thing under test.
|
|
550
|
+
$ok = $true
|
|
551
|
+
try {
|
|
552
|
+
$plain = 'Tb!' + [guid]::NewGuid().ToString('N').Substring(0, 16)
|
|
553
|
+
$pw = ConvertTo-SecureString $plain -AsPlainText -Force
|
|
554
|
+
Get-LocalUser -Name $LocalUser -ErrorAction SilentlyContinue | Remove-LocalUser -ErrorAction SilentlyContinue
|
|
555
|
+
New-LocalUser -Name $LocalUser -Password $pw -AccountNeverExpires -PasswordNeverExpires `
|
|
556
|
+
-UserMayNotChangePassword -Description 'Trillboards update-applier probe' -ErrorAction Stop | Out-Null
|
|
557
|
+
# Users ONLY, never Administrators -- an admin account can create the S4U
|
|
558
|
+
# shape and this arm would assert nothing.
|
|
559
|
+
Add-LocalGroupMember -Group (Get-LocalGroup -SID 'S-1-5-32-545').Name -Member $LocalUser -ErrorAction SilentlyContinue
|
|
560
|
+
& icacls.exe $installK /grant '*S-1-1-0:(OI)(CI)M' /T | Out-Null
|
|
561
|
+
|
|
562
|
+
$cred = New-Object System.Management.Automation.PSCredential($LocalUser, $pw)
|
|
563
|
+
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
564
|
+
# Materialise the profile once, so the run under test is not measuring
|
|
565
|
+
# first-logon profile creation.
|
|
566
|
+
Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
|
|
567
|
+
-WorkingDirectory 'C:\' -Wait -ErrorAction Stop
|
|
568
|
+
|
|
569
|
+
# Drive the COPY inside the install tree, not the one in the repo
|
|
570
|
+
# workspace: the ACL above covers the install tree, and the runner's
|
|
571
|
+
# workspace is not necessarily readable by a fresh local account.
|
|
572
|
+
$applierCopy = Join-Path $installK 'bin\apply-update.ps1'
|
|
573
|
+
$outK = Join-Path $Root 'k-stdout.txt'
|
|
574
|
+
$errK = Join-Path $Root 'k-stderr.txt'
|
|
575
|
+
$null = Start-Process -FilePath $ps -Credential $cred -LoadUserProfile -WorkingDirectory 'C:\' -PassThru -Wait `
|
|
576
|
+
-RedirectStandardOutput $outK -RedirectStandardError $errK `
|
|
577
|
+
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $applierCopy,
|
|
578
|
+
'-InstallDir', $installK, '-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
|
|
579
|
+
foreach ($f in @($outK, $errK)) {
|
|
580
|
+
if (Test-Path -LiteralPath $f) {
|
|
581
|
+
$t = Get-Content -Raw -LiteralPath $f
|
|
582
|
+
if ($t -and $t.Trim()) { Say " [arm K $(Split-Path -Leaf $f)] $($t.Trim())" }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
} catch {
|
|
586
|
+
$ok = $false
|
|
587
|
+
Say "arm K could not run as a standard user on this host: $($_.Exception.Message)"
|
|
588
|
+
Write-Host 'UNPROVEN - arm K: the unelevated caller could not be created, so the would-downgrade refusal is NOT observed on this host'
|
|
589
|
+
$script:Failures++
|
|
590
|
+
}
|
|
591
|
+
if ($ok) {
|
|
592
|
+
$after = Get-Shape $TaskName
|
|
593
|
+
$k = $null
|
|
594
|
+
$op = Join-Path $installK '.auto-update\outcome.json'
|
|
595
|
+
if (Test-Path -LiteralPath $op) { try { $k = Get-Content -LiteralPath $op -Raw | ConvertFrom-Json } catch {} }
|
|
596
|
+
$result = if ($k) { [string]$k.result } else { '' }
|
|
597
|
+
$detail = if ($k -and $k.detail) { [string]$k.detail } else { '' }
|
|
598
|
+
if ($null -ne $after -and $after.Headless -and $result -eq 'rejected' -and $detail -match 'logon-only') {
|
|
599
|
+
Pass 'an unelevated caller REFUSES the update rather than downgrading a headless machine'
|
|
600
|
+
} elseif ($null -ne $after -and $after.Headless -and $result -eq 'success') {
|
|
601
|
+
# The account this ran as could still register the shape. Say so plainly
|
|
602
|
+
# rather than claiming a refusal that did not happen.
|
|
603
|
+
Write-Host 'UNPROVEN - arm K: the standard user was able to re-create the headless shape, so the refusal branch did not fire'
|
|
604
|
+
$script:Failures++
|
|
605
|
+
} else {
|
|
606
|
+
Bad "arm K: headless=$(if ($after) { $after.Headless } else { 'absent' }), result '$result', detail '$detail'"
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
Cleanup
|
|
613
|
+
if ($Failures -gt 0) { Write-Host "$Failures arm(s) failed"; exit 1 }
|
|
614
|
+
Write-Host 'All update-applier arms passed, every negative arm against a control that fired.'
|
|
615
|
+
exit 0
|
|
616
|
+
} catch {
|
|
617
|
+
Write-Host "FAIL - harness error: $($_.Exception.Message)"
|
|
618
|
+
Write-Host $_.ScriptStackTrace
|
|
619
|
+
Cleanup
|
|
620
|
+
exit 1
|
|
621
|
+
}
|
|
@@ -369,6 +369,9 @@ export declare class CloudSensingManager extends EventEmitter {
|
|
|
369
369
|
name: string;
|
|
370
370
|
captureCount: number;
|
|
371
371
|
postCount: number;
|
|
372
|
+
/** Of those posts, how many the server REJECTED. `postCount - postRejected`
|
|
373
|
+
* is the only number that means "observations that were actually stored". */
|
|
374
|
+
postRejected: number;
|
|
372
375
|
faceDetected: boolean;
|
|
373
376
|
currentIntervalMs: number;
|
|
374
377
|
admissionDeniedCount: number;
|
|
@@ -485,6 +488,13 @@ export declare class CloudSensingManager extends EventEmitter {
|
|
|
485
488
|
analyses: number;
|
|
486
489
|
admissionDenied: number;
|
|
487
490
|
motionSkipped: number;
|
|
491
|
+
/**
|
|
492
|
+
* Uploads the SERVER rejected this run. Present because a rejected feed is
|
|
493
|
+
* otherwise indistinguishable from a quiet one: `analyses` simply stops
|
|
494
|
+
* rising, and the `unavailable` beacon that would say so travels over the
|
|
495
|
+
* same rejected credential.
|
|
496
|
+
*/
|
|
497
|
+
rejected: number;
|
|
488
498
|
/** This feed's skips, attributed to the cascade stage that made them. */
|
|
489
499
|
motionSkippedByReason: Record<MotionGateReason, number>;
|
|
490
500
|
/** Candidates this feed is holding right now — "camera 12 is waiting". */
|