M-egui037: enforce control admission and per-family font notices

This commit is contained in:
skylartaylor committed 2026-10-25 06:51:48 +01:00
1 parent 7dbb08827e
commit d13dd1db20
7 files changed
+137 -11

No files matched your search

+3
View File
@@ -32,6 +32,9 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Windows font notice regressions
shell: pwsh
run: ./packaging/windows/test-font-notices.ps1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
+27 -3
View File
@@ -82,6 +82,8 @@ fn startup_deadline_expired() -> String {
/// One request from a client, answered on `reply`.
pub struct ControlRequest {
/// Admission time on the sending thread, including time waiting for the next app tick.
pub since: web_time::Instant,
pub method: String,
pub params: Value,
pub reply: Sender<Reply>,
@@ -98,7 +100,7 @@ impl ControlClient {
/// Queue a request; the reply arrives on the returned receiver once the app has handled it.
pub fn send(&self, method: &str, params: Value) -> Receiver<Reply> {
let (reply, rx) = channel();
let req = ControlRequest { method: method.into(), params, reply };
let req = ControlRequest { since: web_time::Instant::now(), method: method.into(), params, reply };
if let Err(e) = self.tx.send(req) {
let _ = e.0.reply.send(Err("the app has closed".into()));
}
@@ -620,8 +622,13 @@ impl Control {
}
while let Ok(req) = self.rx.try_recv() {
// One admission timestamp per request: queued input, pending frames and an admitted
// screenshot all measure their deadline from this arrival, never from a later phase.
let since = web_time::Instant::now();
// screenshot all measure their deadline from the sending thread, including time
// waiting in this channel, never from a later phase.
let since = req.since;
if web_time::Instant::now().duration_since(since) > timeout() {
let _ = req.reply.send(Err(if ready { startup_deadline_expired() } else { still_starting() }));
continue;
}
if !ready {
if self.deferred.len() < 32 {
self.deferred.push_back(DeferredRequest { request: req, since });
@@ -2384,6 +2391,23 @@ mod lifecycle_tests {
}
}
#[test]
fn a_request_expired_in_the_channel_is_never_dispatched_or_deferred() {
for ready in [false, true] {
let ctx = egui::Context::default();
let (mut control, client) = attach(&ctx);
let mut host = ReadinessHost::new(ready);
let (reply, waiting) = channel();
let since = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
client.tx.send(ControlRequest { since, method: "ui.state".into(), params: json!({}), reply }).unwrap();
control.tick(&ctx, &mut host);
let error = waiting.try_recv().expect("expired channel request replies on the first tick").expect_err("expired request cannot dispatch");
assert!(error.contains(if ready { "finished starting" } else { "still starting" }), "{error}");
assert_eq!(host.states.get(), 0);
assert!(control.deferred.is_empty() && control.pending.is_empty());
}
}
/// A request held during startup keeps its arrival time. Once the app accepts input, one
/// older than the control deadline fails instead of being dispatched into a later UI.
#[test]
+6
View File
@@ -152,6 +152,12 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable.
OFL notices; `-SkipBuild` requires `CRAFT_FONTS_DIR` so they can be staged, or
`-BuiltWithoutCraftFonts` for binaries built without that input, and `test-msi.ps1` checks that
the compiled MSI has OFL notice rows when expected (and none for a no-font build).
Before either package is created, `font-notices.ps1` validates every declared OFL notice in
`fonts/manifest.txt` and the native `fonts/simplified-chinese-manifest.txt` when present
(required when `CRAFT_FONTS_REQUIRED` is set). Missing or empty family notices fail packaging;
one family's notice cannot cover another. The check conservatively includes declared faces
omitted by build-time size caps. `test-font-notices.ps1` exercises these failures in CI.
The supplied checkout must match the existing binaries when using `-SkipBuild`.
That presence check does not establish byte-for-byte payload or installation coverage;
final package qualification still checks all notices against the staged files.
- **Signing:** `packaging/windows/sign.ps1` signs both executables and the MSI with `signtool`
+42
View File
@@ -0,0 +1,42 @@
# Validate every declared native font notice before either Windows package is created.
# Checking all declared faces is deliberately conservative: size-capped faces may not be embedded.
function Get-CraftFontNotices([string] $FontRoot, [bool] $RequireChinese = $false) {
$rootPath = (Resolve-Path -LiteralPath $FontRoot).Path
$notices = @{}
foreach ($name in 'manifest.txt', 'simplified-chinese-manifest.txt') {
$manifest = Join-Path $rootPath "fonts/$name"
if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) {
if ($name -eq 'manifest.txt' -or $RequireChinese) { throw "Missing font manifest: $manifest" }
continue
}
foreach ($line in Get-Content -LiteralPath $manifest) {
$line = $line.Trim()
if (-not $line -or $line.StartsWith('#')) { continue }
$fields = @($line -split ' \| ' | ForEach-Object { $_.Trim() })
if ($fields.Count -lt 6 -or $fields[4] -ne 'OFL-1.1') { throw "Missing OFL metadata in ${manifest}: $line" }
# Manifest notice paths must name a family-local file, never escape the font input.
$relative = $fields[5].Replace('\', '/')
if ($relative -notmatch '^fonts/([a-zA-Z0-9_-]+)/OFL\.txt$') { throw "Invalid font notice path: $relative" }
$family = $Matches[1]
$fontPath = $fields[2].Replace('\', '/')
if ($fontPath -notmatch ('^fonts/' + [regex]::Escape($family) + '/[^/]+$')) {
throw "Font and notice must belong to the same family directory: $fontPath / $relative"
}
$notice = Join-Path $rootPath $relative
if (-not (Test-Path -LiteralPath $notice -PathType Leaf) -or (Get-Item -LiteralPath $notice).Length -eq 0) {
throw "Missing or empty OFL notice for $($fields[0]): $notice"
}
$notices[$family] = Get-Item -LiteralPath $notice
}
}
if ($notices.Count -eq 0) { throw "No declared font notices in $rootPath" }
foreach ($family in @($notices.Keys | Sort-Object)) { $notices[$family] }
}
# MSI FileName may be short|long. Compare the long names with the staged inventory.
function Assert-CraftFontNoticeNames([string[]] $Expected, [string[]] $Actual) {
$names = @($Actual | ForEach-Object { ($_ -split '\|')[-1] })
foreach ($name in $Expected) {
if ($name -notin $names) { throw "The MSI is missing staged font notice: $name" }
}
}
+9 -7
View File
@@ -128,15 +128,14 @@ $Docs = Join-Path $Stage 'font-licences'
New-Item -ItemType Directory -Force -Path $Docs | Out-Null
$HasCraftFontLicences = 0
if ($env:CRAFT_FONTS_DIR) {
foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) {
. (Join-Path $PSScriptRoot 'font-notices.ps1')
# Validate both native manifests before copying anything. One family's notice cannot
# stand in for another's; retain complete files, including copyright and reserved names.
$fontNotices = @(Get-CraftFontNotices $env:CRAFT_FONTS_DIR (Test-Path Env:CRAFT_FONTS_REQUIRED))
foreach ($ofl in $fontNotices) {
Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt")
$HasCraftFontLicences = 1
}
# CRAFT_FONTS_DIR means the build embeds its faces; packaging without their notices would be
# a licence omission. Fail instead of silently shipping HasCraftFontLicences=0.
if ($HasCraftFontLicences -ne 1) {
throw "CRAFT_FONTS_DIR=$env:CRAFT_FONTS_DIR has no fonts\<family>\OFL.txt; refusing to package embedded craft-fonts without their licence notices"
}
}
# ---- end font notices for the MSI --------------------------------------------------------------
@@ -151,7 +150,10 @@ Invoke-Native 'wix build' {
}
# Inspect the built MSI, not just the XML, before signing/publishing it. In a child process, so
# its Windows Installer database handle is gone before signtool opens the MSI.
Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi -CraftFontLicences $HasCraftFontLicences }
Invoke-Native 'test-msi' {
& (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi `
-CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
+43
View File
@@ -0,0 +1,43 @@
param([string] $CraftFontsRoot)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'font-notices.ps1')
$fixture = Join-Path ([IO.Path]::GetTempPath()) ('pdfcraft-font-notices-' + [guid]::NewGuid())
function Assert-Rejected([scriptblock] $Action, [string] $Message) {
$rejected = $false
try { & $Action | Out-Null } catch { $rejected = $true }
if (-not $rejected) { throw "Expected rejection: $Message" }
}
try {
foreach ($family in 'core', 'chinese') {
New-Item -ItemType Directory -Path (Join-Path $fixture "fonts/$family") -Force | Out-Null
Set-Content -LiteralPath (Join-Path $fixture "fonts/$family/OFL.txt") -Value 'Synthetic notice fixture'
}
$core = 'Core | Regular | fonts/core/face.ttf | Latn | OFL-1.1 | fonts/core/OFL.txt'
$chinese = 'Chinese | Regular | fonts/chinese/face.ttf | Hans | OFL-1.1 | fonts/chinese/OFL.txt'
Set-Content (Join-Path $fixture 'fonts/manifest.txt') @('# comment', $core, $core)
Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese
if (@(Get-CraftFontNotices $fixture $true).Count -ne 2) { throw 'Expected both unique family notices' }
Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', 'fonts/core/OFL.txt'))
Assert-Rejected { Get-CraftFontNotices $fixture $true } 'another family cannot supply this notice'
Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese
Remove-Item (Join-Path $fixture 'fonts/chinese/OFL.txt')
Assert-Rejected { Get-CraftFontNotices $fixture $true } 'one remaining OFL cannot cover the Chinese family'
Set-Content (Join-Path $fixture 'fonts/chinese/OFL.txt') '' -NoNewline
Assert-Rejected { Get-CraftFontNotices $fixture $true } 'empty family notice'
Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', '../OFL.txt'))
Assert-Rejected { Get-CraftFontNotices $fixture $true } 'notice path escapes font input'
Remove-Item (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt')
Assert-Rejected { Get-CraftFontNotices $fixture $true } 'required Chinese manifest missing'
if (@(Get-CraftFontNotices $fixture).Count -ne 1) { throw 'Optional Chinese manifest may be absent' }
Set-Content (Join-Path $fixture 'fonts/manifest.txt') 'malformed'
Assert-Rejected { Get-CraftFontNotices $fixture } 'malformed metadata'
Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('SHORT.TXT|OFL-core.txt', 'OFL-chinese.txt')
Assert-Rejected { Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('OFL-core.txt') } 'MSI lost one staged notice'
if ($CraftFontsRoot) {
$actual = @(Get-CraftFontNotices $CraftFontsRoot $true)
Write-Output "Verified $($actual.Count) notices from the actual native manifests"
}
Write-Output 'Font notice regression checks passed'
} finally {
Remove-Item -LiteralPath $fixture -Recurse -Force
}
+7 -1
View File
@@ -8,7 +8,8 @@ param(
[Parameter(Mandatory)] [string] $Path,
# 1 when package.ps1 staged craft-fonts OFL notices, 0 when the binaries embed no craft-fonts.
# -1 (the default) skips the check when the MSI is inspected by hand.
[int] $CraftFontLicences = -1
[int] $CraftFontLicences = -1,
[string] $ExpectedFontNoticesDirectory
)
$ErrorActionPreference = 'Stop'
$Installer = New-Object -ComObject WindowsInstaller.Installer
@@ -93,6 +94,11 @@ if ($CraftFontLicences -eq 1 -and $fontNotices.Count -eq 0) {
if ($CraftFontLicences -eq 0 -and $fontNotices.Count -gt 0) {
throw "the MSI carries craft-font notices that packaging did not stage: $($fontNotices -join ', ')"
}
if ($ExpectedFontNoticesDirectory) {
. (Join-Path $PSScriptRoot 'font-notices.ps1')
$expected = @(Get-ChildItem -LiteralPath $ExpectedFontNoticesDirectory -File -Filter 'OFL-*.txt' | ForEach-Object { $_.Name })
Assert-CraftFontNoticeNames $expected $fontNotices
}
$status = Read-Row 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallProgress'' AND `Control` = ''Status''' 1
Assert-Equal $status[0] 'Please wait while setup completes.' 'Persistent progress message'
Assert-NoRow 'SELECT `Event` FROM `EventMapping` WHERE `Dialog_` = ''InstallProgress'' AND `Control_` = ''Status''' 'progress text subscription'