@rightkit/release 0.2.155 → 0.2.156

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.
@@ -19,8 +19,19 @@
19
19
  # (logged; a retry is scheduled).
20
20
  #
21
21
  # Retry: after a failed repair (exit 1 or 4) the script gives the task a one-shot time trigger at +2, +5, +15, +60 minutes and then
22
- # hourly, for at most 24 hours from the first failure; the trigger is removed on success, when the package is healthy, or when the
23
- # app is uninstalled. State: retry.json. A later logon or event 751 run still starts a repair at any time.
22
+ # hourly, for the first 24 hours from the first failure. After that it does NOT stop: it keeps exactly one attempt per 24 h window
23
+ # (a one-shot trigger 24 h after the previous attempt; retry.json records dailyMode, windowStartEpoch and windowAttempts), never a
24
+ # tight loop. The trigger is removed on success, when the package is healthy, or when the app is uninstalled. A later logon or
25
+ # event 751 run still starts a repair at any time.
26
+ #
27
+ # Event 751: the task's event trigger fires for every package's 751, and Windows event XPath has no contains(), so the task passes the
28
+ # event's EventRecordID ($(EventRecordID) value query) as -EventRecordID and the script reads that event and exits quietly (no
29
+ # repair, last-result.json untouched) unless it names this app's package (Name_ and the publisher id of the family). Logon and
30
+ # retry runs carry no record id and are never filtered; an unreadable event fails open (the repair proceeds).
31
+ #
32
+ # Receipts: last-result.json / install-result.json carry guardSha256 (sha256 of this script file as it is on disk, computed at run
33
+ # time) and guardSha256Lf (same over the LF-normalised text; equals msixRepairGuard().sha256 only when the deployed copy is byte-exact, including the final newline), plus attemptLaunched (an
34
+ # Add-AppxPackage was actually started this run), launchFailure (why none was started) and lastLaunchEpoch (retry.json).
24
35
  #
25
36
  # Uninstall cleanup: a run that finds the package absent (twice, 10 s apart) removes this folder and the task. It runs at the next
26
37
  # logon, the next event 751, or the next retry tick; Windows has no "package removed" trigger for a per-user task.
@@ -31,7 +42,7 @@
31
42
  # Requirement: the app's data directory must be physical (MSIX fileSystemWrite virtualization disabled), otherwise the packaged app
32
43
  # writes this folder into its private LocalCache and the task cannot see it.
33
44
  [CmdletBinding()]
34
- param([switch]$Install, [string]$Pfn = '', [string]$Feed = '')
45
+ param([switch]$Install, [string]$Pfn = '', [string]$Feed = '', [string]$EventRecordID = '')
35
46
  $ErrorActionPreference = 'Stop'
36
47
  $ProgressPreference = 'SilentlyContinue' # Windows PowerShell 5.1 renders a progress bar per downloaded chunk: a 30 MB Invoke-WebRequest -OutFile then takes minutes
37
48
  $GuardVersion = 2
@@ -44,9 +55,25 @@ $ResultPath = Join-Path $Dir $(if ($Install) { 'install-result.json' } else { 'l
44
55
  $Mode = $(if ($Install) { 'install' } else { 'repair' })
45
56
  $RetryDelaysMinutes = @(2, 5, 15, 60) # then hourly
46
57
  $RetryWindowHours = 24
58
+ $DailyWindowHours = 24 # after the first window: one attempt per window
59
+ $script:Launched = $false
60
+ $script:LaunchFailure = ''
47
61
  $PfnPattern = '^[A-Za-z0-9][A-Za-z0-9.\-]*_[a-z0-9]{13}$'
48
62
  $script:Ctx = ''
49
63
 
64
+ function Get-ScriptHashes {
65
+ $h = [ordered]@{ guardSha256 = ''; guardSha256Lf = '' }
66
+ try {
67
+ $bytes = [IO.File]::ReadAllBytes($PSCommandPath)
68
+ $sha = [Security.Cryptography.SHA256]::Create()
69
+ $h.guardSha256 = ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant()
70
+ $lf = (New-Object Text.UTF8Encoding($false)).GetString($bytes).Replace("`r`n", "`n")
71
+ $h.guardSha256Lf = ([BitConverter]::ToString($sha.ComputeHash((New-Object Text.UTF8Encoding($false)).GetBytes($lf))) -replace '-', '').ToLowerInvariant()
72
+ } catch { }
73
+ return $h
74
+ }
75
+ $ScriptHashes = Get-ScriptHashes
76
+
50
77
  function Log([string]$m) {
51
78
  try {
52
79
  if ((Test-Path -LiteralPath $LogPath) -and ((Get-Item -LiteralPath $LogPath).Length -gt 262144)) { Move-Item -LiteralPath $LogPath -Destination ($LogPath + '.old') -Force }
@@ -61,8 +88,11 @@ function Write-Json([string]$path, $obj) {
61
88
  } catch { Log ('could not write ' + $path + ': ' + $_.Exception.Message) }
62
89
  }
63
90
  function Write-Result([string]$status, [int]$code, [string]$detail) {
64
- $r = [ordered]@{ status = $status; code = $code; mode = $Mode; timeUtc = (Get-Date).ToUniversalTime().ToString('o'); guardVersion = $GuardVersion; detail = $detail }
91
+ $r = [ordered]@{ status = $status; code = $code; mode = $Mode; timeUtc = (Get-Date).ToUniversalTime().ToString('o'); guardVersion = $GuardVersion; guardSha256 = $ScriptHashes.guardSha256; guardSha256Lf = $ScriptHashes.guardSha256Lf; detail = $detail }
92
+ if (-not $Install) { $r.attemptLaunched = [bool]$script:Launched; $r.launchFailure = [string]$script:LaunchFailure }
65
93
  $st = Read-Json $RetryPath
94
+ if ($st -and $st.lastLaunchEpoch) { $r.lastLaunchUtc = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.lastLaunchEpoch).ToString('o') }
95
+ if ($st -and $st.dailyMode) { $r.retryDailyMode = $true }
66
96
  if ($st -and $st.nextEpoch) { $r.nextRetryUtc = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.nextEpoch).ToString('o'); $r.retryAttempt = [int]$st.attempt }
67
97
  Write-Json $ResultPath $r
68
98
  }
@@ -79,7 +109,7 @@ function Get-TaskXml([string]$family, $retryLocal) {
79
109
  $id = [Security.Principal.WindowsIdentity]::GetCurrent()
80
110
  $esc = { param($s) [Security.SecurityElement]::Escape([string]$s) }
81
111
  $sub = '<QueryList><Query Id="0" Path="Microsoft-Windows-AppXDeploymentServer/Operational"><Select Path="Microsoft-Windows-AppXDeploymentServer/Operational">*[System[(EventID=751)]]</Select></Query></QueryList>'
82
- $taskArgs = '--headless powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $PSCommandPath + '"'
112
+ $taskArgs = '--headless powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $PSCommandPath + '" -EventRecordID $(EventRecordID)'
83
113
  $retryXml = ''
84
114
  if ($retryLocal) { $retryXml = '<TimeTrigger><StartBoundary>' + ([datetime]$retryLocal).ToString('yyyy-MM-ddTHH:mm:ss') + '</StartBoundary><Enabled>true</Enabled></TimeTrigger>' }
85
115
  return @"
@@ -87,7 +117,7 @@ function Get-TaskXml([string]$family, $retryLocal) {
87
117
  <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
88
118
  <RegistrationInfo><Description>Repair guard for $(& $esc $family): re-adds the app from its App Installer feed when an interrupted update leaves it NeedsRemediation; retries after a failure. Removes itself when the app is uninstalled.</Description></RegistrationInfo>
89
119
  <Triggers>
90
- <EventTrigger><Enabled>true</Enabled><Subscription>$(& $esc $sub)</Subscription></EventTrigger>
120
+ <EventTrigger><Enabled>true</Enabled><Subscription>$(& $esc $sub)</Subscription><ValueQueries><Value name="EventRecordID">Event/System/EventRecordID</Value></ValueQueries></EventTrigger>
91
121
  <LogonTrigger><Enabled>true</Enabled><UserId>$(& $esc $id.Name)</UserId></LogonTrigger>
92
122
  $retryXml
93
123
  </Triggers>
@@ -127,19 +157,41 @@ function Set-Retry([string]$family, [string]$why) {
127
157
  $now = [datetime]::UtcNow
128
158
  $nowEpoch = [math]::Floor(($now - $epoch0.ToUniversalTime()).TotalSeconds)
129
159
  $st = Read-Json $RetryPath
130
- $attempt = 1; $first = $nowEpoch
131
- if ($st -and $st.firstFailureEpoch -and (($nowEpoch - [double]$st.firstFailureEpoch) -lt ($RetryWindowHours * 3600))) { $attempt = [int]$st.attempt + 1; $first = [double]$st.firstFailureEpoch }
160
+ $attempt = 1; $first = $nowEpoch; $daily = $false; $windowStart = $null; $windowAttempts = 0; $lastLaunch = $null
161
+ if ($st -and $st.firstFailureEpoch) {
162
+ $attempt = [int]$st.attempt + 1; $first = [double]$st.firstFailureEpoch
163
+ if ($st.dailyMode) { $daily = $true; if ($st.windowStartEpoch) { $windowStart = [double]$st.windowStartEpoch }; if ($st.windowAttempts) { $windowAttempts = [int]$st.windowAttempts } }
164
+ }
165
+ # only a started installer counts as a launch; a failure before that (feed down, refused, corrupt) keeps the previous value
166
+ if ($st -and $st.lastLaunchEpoch) { $lastLaunch = [double]$st.lastLaunchEpoch }
167
+ if ($script:Launched) { $lastLaunch = $nowEpoch }
168
+ if (-not $daily -and (($nowEpoch - $first) -ge ($RetryWindowHours * 3600))) { $daily = $true }
132
169
  $delay = $(if ($attempt -le $RetryDelaysMinutes.Count) { $RetryDelaysMinutes[$attempt - 1] } else { 60 })
133
170
  $next = $nowEpoch + ($delay * 60)
134
- if (($next - $first) -gt ($RetryWindowHours * 3600)) {
135
- Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; gaveUp = $true; reason = $why })
136
- try { Register-GuardTask $family $null } catch { }
137
- Log ('retry window of ' + $RetryWindowHours + ' h used up after ' + ($attempt - 1) + ' retries; no further automatic retry (next logon / event 751 starts again)')
138
- return
171
+ if (-not $daily -and (($next - $first) -gt ($RetryWindowHours * 3600))) { $daily = $true }
172
+ if ($daily) {
173
+ # first 24 h used up: one attempt per window, the next one a full window after this attempt (never a tight loop)
174
+ $windowStart = $nowEpoch; $windowAttempts += 1
175
+ $delay = $DailyWindowHours * 60
176
+ $next = $nowEpoch + ($delay * 60)
139
177
  }
140
- Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; nextEpoch = $next; delayMinutes = $delay; reason = $why })
178
+ $o = [ordered]@{ attempt = $attempt; firstFailureEpoch = $first; nextEpoch = $next; delayMinutes = $delay; reason = $why }
179
+ if ($daily) { $o.dailyMode = $true; $o.windowStartEpoch = $windowStart; $o.windowAttempts = $windowAttempts }
180
+ if ($lastLaunch) { $o.lastLaunchEpoch = $lastLaunch }
181
+ Write-Json $RetryPath $o
141
182
  $local = $epoch0.ToUniversalTime().AddSeconds($next).ToLocalTime()
142
- try { Register-GuardTask $family $local; Log ('retry ' + $attempt + ' scheduled in ' + $delay + ' min at ' + $local.ToString('s')) } catch { Log ('could not schedule the retry: ' + $_.Exception.Message) }
183
+ try { Register-GuardTask $family $local; Log ('retry ' + $attempt + ' scheduled in ' + $delay + ' min at ' + $local.ToString('s') + $(if ($daily) { ' (daily window ' + $windowAttempts + ')' } else { '' })) } catch { Log ('could not schedule the retry: ' + $_.Exception.Message) }
184
+ }
185
+ function Test-ForeignEvent([string]$family, [string]$recordId) {
186
+ # '' = proceed (not an event run, or the event names this package, or it could not be read); else why the event is not ours
187
+ if (-not $recordId -or $recordId -notmatch '^\d{1,20}$') { return '' }
188
+ try {
189
+ $e = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -FilterXPath ('*[System[EventID=751 and EventRecordID=' + $recordId + ']]') -MaxEvents 1 -ErrorAction Stop
190
+ $text = [string]$e.Message + ' ' + [string]$e.ToXml()
191
+ $name = $family.Substring(0, $family.LastIndexOf('_')); $pub = $family.Substring($family.LastIndexOf('_') + 1)
192
+ if (($text.IndexOf($name + '_', [StringComparison]::OrdinalIgnoreCase) -ge 0) -and ($text.IndexOf($pub, [StringComparison]::OrdinalIgnoreCase) -ge 0)) { return '' }
193
+ return ('event 751 record ' + $recordId + ' does not name ' + $family)
194
+ } catch { Log ('event ' + $recordId + ' unreadable, proceeding: ' + $_.Exception.Message); return '' }
143
195
  }
144
196
 
145
197
  # ------------------------------------------------------------------ package state
@@ -264,6 +316,9 @@ if ((Split-Path -Leaf $Dir) -ne 'repair') { Finish 'refused' 2 ('script folder i
264
316
  if ($feedUri -and -not (Test-AllowedUri $feedUri)) { Finish 'refused' 2 ('guard.json feed uri is not https (or http to loopback): ' + $feedUri) }
265
317
  if ($msix -and -not (Test-AllowedUri $msix)) { Finish 'refused' 2 ('guard.json msix uri is not https (or http to loopback): ' + $msix) }
266
318
  $script:Ctx = $family
319
+ $foreign = Test-ForeignEvent $family $EventRecordID
320
+ if ($foreign) { Log ('ignored: ' + $foreign); exit 0 }
321
+ Log ('guard sha256 ' + $ScriptHashes.guardSha256 + ' (lf ' + $ScriptHashes.guardSha256Lf + ')')
267
322
  $mutex = [Threading.Mutex]::new($false, ('Local\' + $family + '-Repair'))
268
323
  if (-not $mutex.WaitOne(0)) { Log 'another repair run is active; exiting (last-result.json left to that run)'; exit 0 }
269
324
  $tmpMsix = ''
@@ -295,6 +350,7 @@ try {
295
350
  }
296
351
  if (-not $expect.name -or -not $expect.publisher) {
297
352
  Log 'FAILED: expected package identity unknown; refusing to install anything'
353
+ $script:LaunchFailure = 'identity unknown: no installer started'
298
354
  Set-Retry $family 'identity unknown'
299
355
  Write-Result 'failed' 1 'expected package identity unknown'
300
356
  exit 1
@@ -312,6 +368,7 @@ try {
312
368
  $feedOk = $true
313
369
  if ($fi.uri) { $msix = $fi.uri } # newer than the one recorded
314
370
  Log ('Add-AppxPackage -AppInstallerFile ' + $feedUri + ' (declares ' + $fi.version + ')')
371
+ $script:Launched = $true
315
372
  try { Add-AppxPackage -AppInstallerFile $feedUri -ErrorAction Stop } catch { Log ('-AppInstallerFile failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-AppInstallerFile failed: ' + $_.Exception.Message) }
316
373
  if (Test-Healthy $family) {
317
374
  $now = @(Get-Family $family) | Select-Object -First 1
@@ -334,6 +391,7 @@ try {
334
391
  if ($bad) { Log ('REFUSED msix ' + $msix + ': ' + $bad); $why += ('msix refused: ' + $bad) }
335
392
  else {
336
393
  Log ('Add-AppxPackage -Path ' + $msix + ' (' + $mi.kind + ' ' + $mi.version + ')')
394
+ $script:Launched = $true
337
395
  try { Add-AppxPackage -Path $tmpMsix -ErrorAction Stop } catch { Log ('-Path failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-Path failed: ' + $_.Exception.Message) }
338
396
  if (Test-Healthy $family) {
339
397
  $now = @(Get-Family $family) | Select-Object -First 1
@@ -345,6 +403,7 @@ try {
345
403
  }
346
404
  }
347
405
  } else { $why += 'no msix uri recorded' }
406
+ if (-not $script:Launched) { $script:LaunchFailure = 'no installer started: ' + ($why -join ' | ') }
348
407
  $detail = 'FAILED: package still ' + (Get-StateText $family) + '. Reasons: ' + ($why -join ' | ')
349
408
  Set-Retry $family 'repair failed'
350
409
  Write-Result 'failed' 1 $detail
package/msix-signer.mjs CHANGED
@@ -182,19 +182,26 @@ export function signerCertificate(parsed) {
182
182
  return id.serial === parsed.signer.serial && id.issuer.equals(parsed.signer.issuer);
183
183
  } catch { return false; }
184
184
  });
185
- if (match.length !== 1) throw new Error(`signer certificate not found in the signature (${match.length} matches)`);
186
- return new X509Certificate(match[0]);
185
+ // Real Azure Artifact Signing packages embed the signer certificate more than once: drop byte-identical
186
+ // duplicates, then, if distinct certificates still match, keep the one that is not a CA.
187
+ const distinct = [...new Map(match.map((c) => [c.toString("hex"), c])).values()];
188
+ const leaves = distinct.length > 1 ? distinct.filter((c) => { try { return !new X509Certificate(c).ca; } catch { return false; } }) : distinct;
189
+ if (leaves.length !== 1) throw new Error(`signer certificate not found in the signature (${match.length} matches, ${distinct.length} distinct, ${leaves.length} non-CA)`);
190
+ return new X509Certificate(leaves[0]);
187
191
  }
188
192
 
189
193
  // --- subject rendering ---------------------------------------------------------------------------
190
194
  const needsQuote = (v) => /[,+"\\<>;=\r\n]/.test(v) || /^[ #]|\s$/.test(v);
191
- /** Node renders subject as "K=V" lines; join as an RFC 2253-style display string like .NET Subject ("CN=a, O=b"). */
195
+ // Windows (.NET X500DistinguishedName, Get-AuthenticodeSignature, MSIX Identity/@Publisher) names these
196
+ // attributes differently from OpenSSL/Node.
197
+ const WINDOWS_ATTRIBUTE = { ST: "S", emailAddress: "E" };
198
+ /** Render the subject exactly as Windows does: most specific RDN first ("CN=a, O=b, L=c, S=d, C=e"), Windows attribute names. */
192
199
  export function renderSubject(x509) {
193
200
  return String(x509.subject).split("\n").filter(Boolean).map((line) => {
194
201
  const at = line.indexOf("=");
195
202
  const key = line.slice(0, at); const value = line.slice(at + 1);
196
- return `${key}=${needsQuote(value) ? `"${value.replace(/"/g, '""')}"` : value}`;
197
- }).join(", ");
203
+ return `${WINDOWS_ATTRIBUTE[key] ?? key}=${needsQuote(value) ? `"${value.replace(/"/g, '""')}"` : value}`;
204
+ }).reverse().join(", ");
198
205
  }
199
206
 
200
207
  /** Read the signer certificate subject of a signed .msix / .msixbundle from AppxSignature.p7x. Throws MSIX_PUBLISHER_MISMATCH. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.155",
3
+ "version": "0.2.156",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "@rightkit/platform-ui": "0.2.7",
28
28
  "@rightkit/qa": "0.4.7",
29
29
  "@rightkit/qa-chrome": "0.1.5",
30
- "@rightkit/release": "0.2.155",
30
+ "@rightkit/release": "0.2.156",
31
31
  "@rightkit/settings-ui": "0.4.3",
32
32
  "@rightkit/tauri": "0.2.0",
33
33
  "@rightkit/theme": "0.2.3"
@@ -190,7 +190,8 @@
190
190
  "0.2.148",
191
191
  "0.2.149",
192
192
  "0.2.150",
193
- "0.2.151"
193
+ "0.2.151",
194
+ "0.2.155"
194
195
  ],
195
196
  "@rightkit/qa": [
196
197
  "0.1.0",
@@ -443,7 +444,7 @@
443
444
  "@rightkit/platform-ui": "0.2.7",
444
445
  "@rightkit/qa": "0.4.7",
445
446
  "@rightkit/qa-chrome": "0.1.5",
446
- "@rightkit/release": "0.2.151",
447
+ "@rightkit/release": "0.2.155",
447
448
  "@rightkit/settings-ui": "0.4.3",
448
449
  "@rightkit/shell": "0.1.1",
449
450
  "@rightkit/tauri": "0.2.0",