WIP: feat: complete AppKit foundation and release readiness #25

Draft
KofTwentyTwo wants to merge 12 commits from docs/appkit-release-0.1.0-handoff into main
pull from: docs/appkit-release-0.1.0-handoff
84 changed files with 5223 additions and 154 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "5.0.0",
"commands": [
"dotnet-stryker"
]
}
}
}
+5
View File
@@ -21,3 +21,8 @@
- [ ] No secrets, credentials, or personal data
- [ ] New dependencies are justified below, or none were added
- [ ] Substantial AI assistance, if any, is noted here
- [ ] Automated review recorded for the current head commit; maintainer read it and resolved or answered every finding
<!-- Post the review as a separate PR comment using docs/security/scorecard.md.
Re-review and replace the record after any new commit. This checklist alone
does not satisfy the review / automated check. -->
+13
View File
@@ -4,6 +4,19 @@
# updates are not delayed by the cooldown.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
groups:
tooling:
patterns: ["*"]
commit-message:
prefix: build
# Always: keeps SHA-pinned actions and reusable workflows current (K22-CI-11).
- package-ecosystem: github-actions
directory: /
+74
View File
@@ -0,0 +1,74 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
const acknowledgement = "Maintainer acknowledgement: I read the review and resolved or answered every finding.";
/** Accepts only the latest maintainer-owned record for the current commit. */
function evaluateReviewRecord(comments, sha, maintainer)
{
const marker = `<!-- appkit-automated-review:${sha} -->`;
const records = comments.filter(comment =>
comment.user?.login === maintainer
&& comment.user?.type === "User"
&& typeof comment.body === "string"
&& comment.body.includes(marker));
records.sort((left, right) =>
String(right.updated_at).localeCompare(String(left.updated_at)) || right.id - left.id);
const record = records[0];
if(!record)
{
return { passed: false, reason: "No maintainer-owned automated review record exists for the current PR commit." };
}
const lines = record.body.split(/\r?\n/).map(line => line.trim());
const field = name =>
{
const values = lines.filter(line => line.startsWith(`${name}:`));
return values.length === 1 ? values[0].slice(name.length + 1).trim() : null;
};
const passed = lines.includes(marker)
&& ["Codex", "Claude", "Copilot"].includes(field("Tool"))
&& ["none", "resolved"].includes(field("Findings"))
&& (field("Summary")?.length ?? 0) >= 20
&& lines.includes(acknowledgement);
return { passed, reason: passed ? "Current commit reviewed and acknowledged by the maintainer."
: "The latest review record is incomplete or still has unresolved findings." };
}
/** Publishes a check on the PR head without ever fetching or executing its code. */
async function run({ github, context, core })
{
const { owner, repo } = context.repo;
const pullNumber = context.payload.pull_request?.number ?? context.payload.issue?.number;
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber });
if(pull.state !== "open" || pull.base.ref !== "main")
{
core.info("Only open PRs targeting main require a review record.");
return;
}
const sha = pull.head.sha;
// Start failed: an API error must never leave an apparently successful gate.
const { data: check } = await github.rest.checks.create({
owner, repo, name: "review / automated", head_sha: sha,
status: "completed", conclusion: "failure",
output: { title: "Automated review required", summary: "Waiting for validation of the current commit review record." },
});
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pullNumber, per_page: 100,
});
const result = evaluateReviewRecord(comments, sha, "KofTwentyTwo");
await github.rest.checks.update({
owner, repo, check_run_id: check.id,
status: "completed", conclusion: result.passed ? "success" : "failure",
output: { title: result.passed ? "Automated review recorded" : "Automated review required", summary: result.reason },
});
if(!result.passed)
{
core.setFailed(result.reason);
}
}
module.exports = { evaluateReviewRecord, run };
+144
View File
@@ -0,0 +1,144 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { evaluateReviewRecord, run } = require("./review-gate.cjs");
const sha = "a".repeat(40);
/** A maintainer-recorded review of the current commit. */
function record(overrides = {})
{
return {
id: 1, updated_at: "2026-10-10T12:00:00Z", user: { login: "KofTwentyTwo", type: "User" },
body: `<!-- appkit-automated-review:${sha} -->\nTool: Codex\nFindings: none\n`
+ "Summary: Reviewed input validation, workflow permissions, and regression coverage.\n"
+ "Maintainer acknowledgement: I read the review and resolved or answered every finding.",
...overrides,
};
}
test("accepts a current complete record from the maintainer", () =>
{
assert.equal(evaluateReviewRecord([record()], sha, "KofTwentyTwo").passed, true);
});
test("rejects stale commits, outsiders, bots, missing acknowledgement and unresolved findings", () =>
{
const cases = [
[],
[record({ body: record().body.replace(sha, "b".repeat(40)) })],
[record({ user: { login: "contributor", type: "User" } })],
[record({ user: { login: "KofTwentyTwo", type: "Bot" } })],
[record({ body: record().body.replace("Maintainer acknowledgement:", "Pending acknowledgement:") })],
[record({ body: record().body.replace("Findings: none", "Findings: unresolved") })],
[record({ body: record().body.replace("Summary:", "No summary:") })],
[record({ body: record().body + "\nFindings: unresolved" })],
[record({ body: record().body + "\nTool: Unknown" })],
[record({ body: record().body.replace("Tool: Codex", "Tool: Unknown") })],
[record({ body: record().body.replace("Summary: Reviewed input validation, workflow permissions, and regression coverage.", "Summary: Too short.") })],
];
for(const comments of cases)
{
assert.equal(evaluateReviewRecord(comments, sha, "KofTwentyTwo").passed, false);
}
});
test("a newer unresolved record overrides an older passing record", () =>
{
const latest = record({ id: 2, updated_at: "2026-10-10T13:00:00Z", body: record().body.replace("Findings: none", "Findings: unresolved") });
assert.equal(evaluateReviewRecord([record(), latest], sha, "KofTwentyTwo").passed, false);
});
test("an edited record supersedes the previous review", () =>
{
const updated = record({ updated_at: "2026-10-10T14:00:00Z", body: record().body.replace("Findings: none", "Findings: resolved") });
const previous = record({ id: 2, updated_at: "2026-10-10T13:00:00Z", body: record().body.replace("Findings: none", "Findings: unresolved") });
assert.equal(evaluateReviewRecord([previous, updated], sha, "KofTwentyTwo").passed, true);
});
test("publishes to the live PR head and paginates review comments", async () =>
{
const writes = [];
const github = {
rest: {
pulls: { get: async () => ({ data: { state: "open", base: { ref: "main" }, head: { sha } } }) },
issues: { listComments: "comments" },
checks: {
create: async input =>
{
writes.push(input); return { data: { id: 9 } };
},
update: async input =>
{
writes.push(input);
},
},
},
paginate: async (method, input) =>
{
assert.equal(method, "comments");
assert.equal(input.issue_number, 7);
return [record()];
},
};
await run({ github, context: { repo: { owner: "KofTwentyTwo", repo: "AppKit" }, payload: { issue: { number: 7 } } },
core: { setFailed: assert.fail, info: () =>
{} } });
assert.equal(writes[0].head_sha, sha);
assert.equal(writes[0].conclusion, "failure");
assert.equal(writes[1].check_run_id, 9);
assert.equal(writes[1].conclusion, "success");
});
test("an API failure leaves the current head check failed", async () =>
{
const writes = [];
const github = {
rest: {
pulls: { get: async () => ({ data: { state: "open", base: { ref: "main" }, head: { sha } } }) },
issues: { listComments: "comments" },
checks: {
create: async input =>
{
writes.push(input); return { data: { id: 9 } };
},
update: async input =>
{
writes.push(input);
},
},
},
paginate: async () =>
{
throw new Error("API unavailable");
},
};
await assert.rejects(run({ github,
context: { repo: { owner: "KofTwentyTwo", repo: "AppKit" }, payload: { issue: { number: 7 } } },
core: { setFailed: assert.fail, info: () =>
{} },
}), /API unavailable/);
assert.equal(writes.length, 1);
assert.equal(writes[0].head_sha, sha);
assert.equal(writes[0].conclusion, "failure");
});
test("ignores closed PRs and PRs targeting another branch", async () =>
{
for(const pull of [{ state: "closed", base: { ref: "main" } }, { state: "open", base: { ref: "other" } }])
{
let ignored = false;
await run({
github: { rest: { pulls: { get: async () => ({ data: pull }) } } },
context: { repo: { owner: "KofTwentyTwo", repo: "AppKit" }, payload: { issue: { number: 7 } } },
core: { info: () =>
{
ignored = true;
}, setFailed: assert.fail },
});
assert.equal(ignored, true);
}
});
+3 -2
View File
@@ -7,16 +7,17 @@ on:
pull_request:
push:
branches: [main]
workflow_call:
permissions: {}
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
ci:
uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # check out, build, and test; nothing is written
with:
+4 -3
View File
@@ -8,21 +8,22 @@ on:
branches: [main]
schedule:
- cron: "41 6 * * 1"
workflow_call:
permissions: {}
concurrency:
group: codeql-${{ github.ref }}
group: codeql-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
codeql:
uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # check out the code
security-events: write # upload CodeQL results to code scanning
with:
languages: '["csharp", "actions"]'
languages: '["csharp", "actions", "javascript-typescript"]'
build-mode: manual
build-command: dotnet build AppKit.slnx -p:Platform=x64
runs-on: windows-latest
+42
View File
@@ -0,0 +1,42 @@
# K22-TEST-11: retain mutation evidence before publishing library releases.
name: mutation
on:
workflow_dispatch:
workflow_call:
permissions: {}
jobs:
libraries:
name: ${{ matrix.project }}
runs-on: windows-latest
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
matrix:
project: [KofTwentyTwo.AppKit, KofTwentyTwo.AppKit.Updates]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- name: Run pinned mutation tool
shell: pwsh
env:
MUTATION_PROJECT: ${{ matrix.project }}
run: ./build/Invoke-MutationTests.ps1 -Projects $env:MUTATION_PROJECT
- name: Retain mutation evidence
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mutation-${{ matrix.project }}
path: artifacts/mutation
if-no-files-found: error
retention-days: 30
+15 -1
View File
@@ -13,8 +13,22 @@ concurrency:
cancel-in-progress: true
jobs:
review-tests:
name: review / tests
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test review record validation
run: node --test .github/scripts/review-gate.test.cjs
pr:
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # read the commits being merged
pull-requests: read # read the pull request title and dependency diff
+92 -2
View File
@@ -28,8 +28,99 @@ concurrency:
cancel-in-progress: false
jobs:
verify:
name: verify
runs-on: ubuntu-24.04
timeout-minutes: 10
environment: release
permissions:
contents: read
steps:
- name: Checkout main and tag history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Require the tagged commit to be on main
shell: bash
run: |
set -euo pipefail
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Require NuGet publishing configuration
env:
NUGET_USER: ${{ vars.NUGET_USER }}
run: test -n "$NUGET_USER"
- name: Require signing integration before publishing another release
shell: pwsh
env:
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }}
ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }}
ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE }}
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
if (-not (Select-String -LiteralPath .github/workflows/release.yml -Pattern '^\s+sign: true\s*$' -Quiet))
{
throw 'Release signing is required. Publish the prepared standards signing change, update the shared builder pin, and configure sign: true. See docs/release-signing.md.'
}
foreach ($name in @('AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID', 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT'))
{
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { throw "Missing verified signing variable: $name" }
}
gates:
needs: verify
uses: ./.github/workflows/ci.yml
github-advanced-security[bot] commented 2026-10-10 22:52:37 +00:00 (Migrated from github.com)
Review

zizmor /

use GitHub's dedicated self-repository syntax: use '$/...' instead of './...'

Show more details

## zizmor / use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' [Show more details](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/6)
permissions:
contents: read
security:
needs: verify
uses: ./.github/workflows/security.yml
github-advanced-security[bot] commented 2026-10-10 22:52:37 +00:00 (Migrated from github.com)
Review

zizmor /

use GitHub's dedicated self-repository syntax: use '$/...' instead of './...'

Show more details

## zizmor / use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' [Show more details](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/7)
permissions:
contents: read
security-events: write
codeql:
needs: verify
uses: ./.github/workflows/codeql.yml
github-advanced-security[bot] commented 2026-10-10 22:52:37 +00:00 (Migrated from github.com)
Review

zizmor /

use GitHub's dedicated self-repository syntax: use '$/...' instead of './...'

Show more details

## zizmor / use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' [Show more details](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/8)
permissions:
contents: read
security-events: write
review-tests:
name: review / tests
needs: verify
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test review validation
run: node --test .github/scripts/review-gate.test.cjs
scripts:
needs: verify
uses: ./.github/workflows/scripts.yml
github-advanced-security[bot] commented 2026-10-10 22:52:37 +00:00 (Migrated from github.com)
Review

zizmor /

use GitHub's dedicated self-repository syntax: use '$/...' instead of './...'

Show more details

## zizmor / use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' [Show more details](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/9)
permissions:
contents: read
mutation:
needs: verify
uses: ./.github/workflows/mutation.yml
github-advanced-security[bot] commented 2026-10-10 22:52:37 +00:00 (Migrated from github.com)
Review

zizmor /

use GitHub's dedicated self-repository syntax: use '$/...' instead of './...'

Show more details

## zizmor / use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' [Show more details](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/10)
permissions:
contents: read
release:
uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
needs: [verify, gates, security, codeql, review-tests, scripts, mutation]
uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: write # create and publish the GitHub release
id-token: write # sign attestations
@@ -44,7 +135,6 @@ jobs:
publish:
name: publish (nuget.org)
needs: release
if: vars.NUGET_USER != ''
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: release
+36
View File
@@ -0,0 +1,36 @@
# Single-maintainer automated review record (K22-SDLC-12 / EX-0001).
# Privileged events execute only code from the trusted workflow commit, never a PR head.
name: review
on:
issue_comment:
types: [created, edited, deleted]
permissions: {}
concurrency:
group: review-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
record:
if: github.event.issue.pull_request
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
issues: read
checks: write # publish the required check on the live PR head
steps:
- name: Checkout trusted gate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Verify the current commit review record
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { run } = require('./.github/scripts/review-gate.cjs');
await run({ github, context, core });
+71
View File
@@ -0,0 +1,71 @@
# K22-CODE-TS / K22-CODE-SH: repository tooling is checked before merges and releases.
name: scripts
on:
pull_request:
push:
branches: [main]
workflow_call:
permissions: {}
jobs:
javascript:
name: javascript
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
- name: Install locked development tools without lifecycle scripts
run: npm ci --ignore-scripts
- name: Lint JavaScript
run: npm run lint
- name: Test review validation
run: npm test
- name: Verify dependency signatures
run: npm audit signatures
powershell:
name: powershell
runs-on: windows-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install pinned analyzer and check scripts
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
# Analyze each file separately: recursive analysis can crash inside 1.25.0.
foreach($script in Get-ChildItem -LiteralPath build -Recurse -File | Where-Object Extension -In '.ps1', '.psm1', '.psd1')
{
$findings = @(Invoke-ScriptAnalyzer -Path $script.FullName -Settings ./PSScriptAnalyzerSettings.psd1)
if($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $($script.Name)" }
}
- name: Verify coverage and signing gate rejection paths
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
Import-Module Pester -RequiredVersion 5.7.1
$testConfiguration = New-PesterConfiguration
$testConfiguration.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/WindowsSigning.Tests.ps1', 'build/ArtifactSigning.Tests.ps1')
$testConfiguration.Run.Exit = $true
$testConfiguration.TestResult.Enabled = $true
$testConfiguration.TestResult.OutputPath = 'TestResults/coverage-gate.xml'
Invoke-Pester -Configuration $testConfiguration
+3 -2
View File
@@ -10,16 +10,17 @@ on:
schedule:
- cron: "23 6 * * 1"
workflow_dispatch:
workflow_call:
permissions: {}
concurrency:
group: security-${{ github.ref }}
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
security:
uses: KofTwentyTwo/standards/.github/workflows/security.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
uses: KofTwentyTwo/standards/.github/workflows/security.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # check out the code and its history
security-events: write # upload Trivy and zizmor SARIF to code scanning
+1
View File
@@ -292,6 +292,7 @@ FakesAssemblies/
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
testResults.xml
# Visual Studio 6 build log
*.plg
+68
View File
@@ -0,0 +1,68 @@
# KofTwentyTwo pre-commit configuration. Works with pre-commit and prek.
# Copy to the repository root as .pre-commit-config.yaml, then install both hook
# types once per clone:
#
# pre-commit install --hook-type pre-commit --hook-type commit-msg
#
# Every external hook is pinned to a full commit SHA with the release in a comment
# (`pre-commit autoupdate --freeze` keeps that format). The hooks are the local half
# of the CI gates in standards/ci-cd.md; CI runs the same tools again.
default_install_hook_types: [pre-commit, commit-msg]
default_stages: [pre-commit]
repos:
# File hygiene.
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
hooks:
- id: check-added-large-files
args: [--maxkb=1024]
- id: check-case-conflict
- id: check-merge-conflict
- id: check-yaml
- id: check-json
- id: check-toml
- id: detect-private-key
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=lf]
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: no-commit-to-branch
args: [--branch, main]
# Secrets (K22-CI-20): staged changes only, so it stays fast.
- repo: https://github.com/gitleaks/gitleaks
rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1
hooks:
- id: gitleaks
# Workflow security (K22-CI-23).
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1
hooks:
- id: zizmor
args: [--min-severity, medium]
- repo: https://github.com/rhysd/actionlint
rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12
hooks:
- id: actionlint
# Commit message: Conventional Commits header (K22-SDLC commit rules).
- repo: https://github.com/compilerla/conventional-pre-commit
rev: 91ab4bf57e58b32adf1a122681f6ebe164d081c8 # frozen: v4.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [--strict, build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test]
# Commit message: DCO sign-off (`git commit -s`). pygrep with --negate fails when
# no line of the message matches.
- repo: local
hooks:
- id: dco-sign-off
name: DCO sign-off present (git commit -s)
language: pygrep
entry: '^Signed-off-by: .+ <.+@.+>$'
args: [--negate]
stages: [commit-msg]
+43
View File
@@ -0,0 +1,43 @@
# Repository Guidelines
## Project Structure & Module Organization
AppKit provides shared .NET 10 foundations for Windows apps. `AppKit.slnx` groups four packages in `src/`: core services, Velopack updates, WinUI adapters, and WPF adapters. Keep testable logic in core or updates; UI packages contain code-built views and framework adapters.
`tests/` contains unit and UI tests. `samples/` contains runnable WinUI and WPF apps; shared code and brand assets live in `samples/Shared/`. `tools/BrandTool/` generates icons, `build/` holds the coverage gate, and `docs/` contains ADRs, release instructions, and the threat model.
## Build, Test, and Development Commands
Use Windows 10 1809+ and the .NET 10 SDK selected by `global.json`. Run from the repository root:
```powershell
# Restore the locked dependencies and build with zero warnings.
dotnet restore AppKit.slnx -p:Platform=x64 --locked-mode
dotnet build AppKit.slnx -p:Platform=x64 -warnaserror
# Run unit tests and enforce coverage.
dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates
# Drive both built samples; requires an interactive desktop.
dotnet test tests/KofTwentyTwo.AppKit.UiTests
# Verify formatting and run the WPF sample locally.
dotnet format AppKit.slnx --verify-no-changes --severity warn --no-restore
dotnet run --project samples/AppKit.Sample.Wpf -p:Platform=x64
# Produce release packages.
dotnet pack AppKit.slnx -c Release -p:Platform=x64 -o artifacts/nuget
```
## Coding Style & Naming Conventions
Follow `.editorconfig`: three-space indentation, LF endings, Allman braces, file-scoped namespaces, and `if(` spacing. YAML uses two spaces. Use PascalCase types/members, `I`-prefixed interfaces, `_camelCase` private fields, and `s_camelCase` private static fields. Use `var` only when the type is apparent. Include type/method header comments and three blank lines between members. Builds enforce style and Meziantou analyzers; check linked sample files for conflict markers after formatting.
## Testing Guidelines
Use xUnit, Coverlet for coverage, and FlaUI/UIA3 for UI automation. Name tests `Method_Scenario_Expectation`. Core and updates must retain 100% line coverage. Add regression tests for fixes; exercise shell features in both samples and keep WinUI/WPF behavior aligned.
## Commit & Pull Request Guidelines
Recent history uses `chore: ...`; follow Conventional Commits (`fix(updates): ...`). Branch from `main` as `<type>/<description>`. Sign and sign off commits with `git commit -S -s`. Target PRs at `main` with a Conventional Commit title for squash merging. Explain what changed, why, linked issues, and validation; include screenshots for UI changes. Justify dependencies, disclose substantial AI assistance, update relevant docs, and resolve checks/review threads. See `CONTRIBUTING.md` for the full checklist.
## Dependencies & Security
Manage versions in `Directory.Packages.props` and regenerate affected `packages.lock.json` files. Never commit secrets or personal data. Update `docs/security/threat-model.md` for attack-surface changes; follow `SECURITY.md` for private vulnerability reports.
+39
View File
@@ -0,0 +1,39 @@
# Changelog
Release versions follow [Semantic Versioning](https://semver.org/). All four AppKit
packages release together. Unreleased changes are listed before tagging; published
versions retain their original package contents.
## Unreleased — 0.2.0
- Bound each log-viewer refresh to 1 MiB of trailing input plus the encoding header,
including huge single lines. Preserve supported BOM encodings and show a notice
when older input is omitted, including a persistent toolbar notice while following
new entries. WPF follows the newest entry on first open and subsequent refreshes.
Existing two-argument log-reader calls remain compatible.
- Add an `ILogger` adapter for activity logs with original message templates, typed
scalar fields, event IDs, and asynchronous scopes stored as JSON. Existing
`IActivityLog` methods and third-party implementations remain compatible. Foundation
update/crash events and samples use source-generated log methods. The official
`Microsoft.Extensions.Logging.Abstractions` package is a new runtime dependency.
## 0.1.0 — 2026-10-10
Initial public release for .NET 10 Windows desktop applications:
- Core package: app identity and paths, build version, settings persistence and repair,
activity logs, credential storage, credits, and user interaction abstractions.
- Updates package: Velopack startup, GitHub release updates, and a shared update
coordinator with errors returned to the caller.
- WinUI and WPF packages: code-built splash, About, and log views; framework adapters
for dialogs, theming, window behavior, and crash handling.
- Runnable WinUI/WPF samples, unit and UI automation tests, full core/update line
coverage, and enforced build, formatting, dependency, and security checks.
- GitHub release assets include NuGet/symbol packages, per-package SBOMs, SHA-256
checksums, and signed build provenance. NuGet publishing uses trusted publishing.
This is the first release, so there is no earlier AppKit version to upgrade from.
The UI packages and Windows credential backend require Windows; see README for
package-specific target frameworks and setup.
[GitHub release](https://github.com/KofTwentyTwo/AppKit/releases/tag/v0.1.0)
+9 -1
View File
@@ -14,13 +14,18 @@ dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --co
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates
dotnet test tests/KofTwentyTwo.AppKit.UiTests # launches both sample apps on the desktop
dotnet format AppKit.slnx --verify-no-changes --severity warn --no-restore
npm ci --ignore-scripts
npm run lint
npm test
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
Invoke-ScriptAnalyzer -Path build -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit
pwsh ../standards/tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly
```
## Rules
- GitHub Flow: topic branch `<type>/<desc>` → PR to `main` → squash merge. No `dev` branch.
Conventional Commit titles, `git commit -s` (DCO), signed commits. Agents never merge,
Conventional Commit titles, `git commit -S -s` (signature and DCO). Agents never merge,
tag, release, or change repository settings.
- Zero warnings; 100% line coverage for `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates`.
- Testable logic lives in those two packages; WinUI/WPF packages hold views and adapters only.
@@ -35,3 +40,6 @@ pwsh ../standards/tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly
solution only after checking it did not write merge-conflict markers into them.
- Attack-surface changes update `docs/security/threat-model.md`; expensive-to-reverse
decisions get an ADR in `docs/adr/`.
- Significant AI contributions include a model-naming `Co-Authored-By` trailer and
disclosure in the PR. The maintainer acknowledges the current-commit automated
review before merging; follow `docs/security/scorecard.md`.
+36
View File
@@ -26,6 +26,19 @@ dotnet build AppKit.slnx -p:Platform=x64 -warnaserror
dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates
# Before a minor library release: mutation-test both complete library projects.
./build/Invoke-MutationTests.ps1
# Generated input tests (also included in the unit suite); Node 24 for repository tools
dotnet test tests/KofTwentyTwo.AppKit.Tests --filter FullyQualifiedName~InputProperties
npm ci --ignore-scripts
npm run lint
npm test
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
Invoke-ScriptAnalyzer -Path build -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit
Import-Module Pester -RequiredVersion 5.7.1
Invoke-Pester -Path build/Assert-Coverage.Tests.ps1 -CI
# End-to-end UI tests: FlaUI launches both sample apps (needs an interactive desktop)
dotnet test tests/KofTwentyTwo.AppKit.UiTests
@@ -40,6 +53,15 @@ ReSharper apply the blank lines from `.editorconfig`). Changing a package versio
updating `Directory.Packages.props` and committing the regenerated `packages.lock.json`
files (`dotnet restore AppKit.slnx -p:Platform=x64 --force-evaluate`).
Mutation testing uses repository-local Stryker.NET 5.0.0 and writes HTML/JSON reports
under `artifacts/mutation/`. Review every surviving or uncovered core mutation:
add a meaningful test or record why the mutation is equivalent or deliberately
accepted. Keep the report and decisions in the release checklist. The tool's default
score thresholds are advisory; successful execution alone does not approve survivors.
To repeat one library, use `./build/Invoke-MutationTests.ps1 -Projects KofTwentyTwo.AppKit`.
See [the mutation audit](docs/testing/mutation-testing.md) for current results and
explicit survivor decisions, including native cleanup limits.
Install the git hooks once per clone. They run the same secret, workflow, and
commit-message checks CI does:
@@ -60,6 +82,9 @@ pre-commit install --hook-type pre-commit --hook-type commit-msg
message on `main`.
4. Every required check must pass, and every review thread must be resolved, before
the pull request merges.
5. With one maintainer, record an automated review of the **current head commit** and
acknowledge its findings before merging. Follow the [review record instructions](docs/security/scorecard.md#recording-an-automated-review).
CodeQL is also required. A new commit invalidates the previous review record.
## What makes a contribution acceptable
@@ -89,6 +114,15 @@ pre-commit install --hook-type pre-commit --hook-type commit-msg
| `ci / build-test` | Locked restore, zero-warning build, unit tests, 100% coverage gate, packages pack |
| `ci / format` | `dotnet format --verify-no-changes --severity warn` |
| `ci / ui-tests` | FlaUI end-to-end tests of both sample apps |
| `review / tests` | Review gate rejects stale, incomplete, and unauthorized records |
| `scripts / javascript`, `scripts / powershell` | Shared lint rules, review tests, and tooling dependency signatures |
| `review / automated` | Current commit has a maintainer-acknowledged automated review (enable after bootstrap) |
The four FsCheck properties in `InputProperties.cs` each generate 500 cases during
the normal unit run. They exercise settings repair, malformed bytes, JSON round trips,
and bounded log tails. A failure reports a shrunk counterexample and replay seed;
reproduce it with the failing property's `Replay` option, then retain a fixed regression
test. FsCheck is a test-only dependency; it is not shipped in AppKit packages.
## Sign-off (Developer Certificate of Origin)
@@ -109,6 +143,8 @@ branch, run `git rebase --signoff main` and force-push the branch.
AI coding tools are welcome. You remain the author: you must understand, test, and be
able to explain every line you submit, and you sign it off as your own. Note
substantial AI assistance in the pull request description.
Include a `Co-Authored-By:` trailer naming the model for significant AI contributions
(K22-AGENT-40 / K22-AI-02).
## License
+1 -1
View File
@@ -54,7 +54,7 @@
-p:Version=<tag-version>, which overrides both parts. SourceLink appends the
git commit as "+<sha>" on AssemblyInformationalVersion. -->
<PropertyGroup>
<VersionPrefix>0.1.0</VersionPrefix>
<VersionPrefix>0.2.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
+2
View File
@@ -13,6 +13,7 @@
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageVersion Include="Velopack" Version="1.2.161" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
@@ -21,6 +22,7 @@
<ItemGroup Label="Tests">
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="FsCheck.Xunit" Version="3.4.0" />
<PackageVersion Include="FlaUI.UIA3" Version="5.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
+56
View File
@@ -0,0 +1,56 @@
# KofTwentyTwo PowerShell analysis (K22-CODE-SH-05). CI runs:
# Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit
# and `Invoke-Formatter` with the same settings for layout. PowerShell supports the
# Kingsrook layout fully: next-line (Allman) braces and 3-space indentation.
@{
Severity = @('Error', 'Warning', 'Information')
IncludeDefaultRules = $true
ExcludeRules = @()
Rules = @{
PSPlaceOpenBrace = @{
Enable = $true
OnSameLine = $false
NewLineAfter = $true
IgnoreOneLineBlock = $true
}
PSPlaceCloseBrace = @{
Enable = $true
NewLineAfter = $true
IgnoreOneLineBlock = $true
NoEmptyLineBefore = $true
}
PSUseConsistentIndentation = @{
Enable = $true
IndentationSize = 3
Kind = 'space'
PipelineIndentation = 'IncreaseIndentationForFirstPipeline'
}
PSUseConsistentWhitespace = @{
Enable = $true
CheckInnerBrace = $true
# Kingsrook writes if(...) with no space; the rule cannot require that, so it
# is not checked either way.
CheckOpenParen = $false
CheckOpenBrace = $false
# Kingsrook aligns the `=` of consecutive assignments (PSAlignAssignmentStatement
# below); CheckOperator would then flag every aligned line, so it is off.
CheckOperator = $false
CheckPipe = $true
CheckSeparator = $true
CheckParameter = $true
}
PSAlignAssignmentStatement = @{
Enable = $true
CheckHashtable = $true
}
PSUseCorrectCasing = @{ Enable = $true }
PSAvoidUsingCmdletAliases = @{ Enable = $true }
PSAvoidUsingWriteHost = @{ Enable = $true }
PSProvideCommentHelp = @{
Enable = $true
ExportedOnly = $false
BlockComment = $true
Placement = 'before'
}
}
}
+11 -2
View File
@@ -16,7 +16,7 @@ shipped in a 1.0 release first.
| --- | --- |
| **Tier** | Product ([scope](https://github.com/KofTwentyTwo/standards/blob/main/policies/README.md#scope)) |
| **Standards** | Adopting [KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards) (pre-v1.0); this README will say *Conforms to KofTwentyTwo standards v1.0* once the conformance checker passes |
| **Latest release** | [Releases](https://github.com/KofTwentyTwo/AppKit/releases/latest) (none yet) |
| **Latest release** | [v0.1.0](https://github.com/KofTwentyTwo/AppKit/releases/tag/v0.1.0) |
## Packages
@@ -75,7 +75,8 @@ internal static class MyApp
}
// Program.Main (with DISABLE_XAML_GENERATED_MAIN): Velopack goes first.
VelopackStartup.Run();
// Keep this direct call so vpk can verify startup in the entry assembly.
Velopack.VelopackApp.Build().Run();
// App constructor
WinUIShell.InstallCrashNet(this, () => Log);
@@ -149,6 +150,14 @@ Only the latest released minor version receives fixes, including security fixes;
Report vulnerabilities privately; see [SECURITY.md](SECURITY.md). The attack surface is
described in the [threat model](docs/security/threat-model.md).
Repository-practice findings and the single-maintainer review policy are documented
in [the Scorecard assessment](docs/security/scorecard.md). The
[OpenSSF Best Practices worksheet](docs/security/openssf-best-practices.md) tracks
evidence and outstanding owner attestations; no certification is claimed yet.
Unreleased 0.2.0 adds [structured logging](src/KofTwentyTwo.AppKit/README.md#structured-logging)
and bounded log reads; these capabilities are not part of the published 0.1.0 packages.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Releases: [docs/RELEASING.md](docs/RELEASING.md).
+112
View File
@@ -0,0 +1,112 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
BeforeAll `
{
$script:IdentityScript = Join-Path $PSScriptRoot 'New-ArtifactSigningIdentity.ps1'
}
Describe 'Azure signing administration boundaries' `
{
BeforeEach `
{
$script:Root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $script:Root | Out-Null
$script:ConfigurationPath = Join-Path $script:Root 'configuration.json'
$script:FixturePath = Join-Path $script:Root 'fixture.json'
$script:LogPath = Join-Path $script:Root 'commands.txt'
$script:Configuration = @{
TenantId = '11111111-1111-1111-1111-111111111111'
SubscriptionId = '8ef2479e-3346-4cba-93ad-101c6cd237a0'
CertificateProfileName = 'existing-profile'
PublisherSubject = 'CN=Fixture Publisher'
ProfileResourceId = '/subscriptions/8ef2479e-3346-4cba-93ad-101c6cd237a0/resourceGroups/rg-code-signing/providers/Microsoft.CodeSigning/codeSigningAccounts/kof22signing/certificateProfiles/existing-profile'
}
$script:Fixture = @{
TenantId = $script:Configuration.TenantId
DefaultTenantId = $script:Configuration.TenantId
Subject = $script:Configuration.PublisherSubject
TrustSubject = 'repo:KofTwentyTwo/AppKit:environment:release'
}
$script:Configuration | ConvertTo-Json | Set-Content -LiteralPath $script:ConfigurationPath
$script:Fixture | ConvertTo-Json | Set-Content -LiteralPath $script:FixturePath
$script:Stub = Join-Path $script:Root 'azure-stub.ps1'
# The external fake accepts the same argv as az; it has no credentials or network.
@'
param([Parameter(ValueFromRemainingArguments)] [string[]] $Arguments)
$ErrorActionPreference = 'Stop'
$fixture = Get-Content (Join-Path $PSScriptRoot 'fixture.json') -Raw | ConvertFrom-Json
Add-Content -LiteralPath (Join-Path $PSScriptRoot 'commands.txt') -Value ($Arguments -join ' ')
$roleId = '/subscriptions/8ef2479e-3346-4cba-93ad-101c6cd237a0/providers/Microsoft.Authorization/roleDefinitions/22222222-2222-2222-2222-222222222222'
$key = $Arguments[0..1] -join ' '
$result = switch ($key) {
'account show' { @{tenantId=$(if ($Arguments -contains '--subscription') {$fixture.TenantId} else {$fixture.DefaultTenantId})} }
'rest --method' { @{properties=@{status='Active';profileType='PublicTrust';certificates=@(@{subjectName=$fixture.Subject})}} }
'role definition' { @(@{id=$roleId;roleType='BuiltInRole'}) }
'ad app' {
if ($Arguments[2] -eq 'show') { @{id='app-object';appId='33333333-3333-3333-3333-333333333333'} }
elseif ($Arguments[2] -eq 'federated-credential' -and $Arguments[3] -eq 'list') {
@(@{name='github-appkit-release';subject=$fixture.TrustSubject;issuer='https://token.actions.githubusercontent.com';audiences=@('api://AzureADTokenExchange')},
@{name='other-repo';subject='repo:KofTwentyTwo/OtherApp:environment:release';issuer='https://token.actions.githubusercontent.com';audiences=@('api://AzureADTokenExchange')})
}
else { throw 'Unexpected Entra mutation in fixture.' }
}
'ad sp' { @(@{id='principal-object'}) }
'role assignment' { @(@{roleDefinitionId=$roleId}) }
default { throw "Unexpected command: $key" }
}
ConvertTo-Json -InputObject $result -Depth 6 -Compress
'@ | Set-Content -LiteralPath $script:Stub
}
It 'reuses the CI identity, exact federation and signer assignment without writes' `
{
$result = & $script:IdentityScript -ConfigurationPath $script:ConfigurationPath -ExistingClientId '33333333-3333-3333-3333-333333333333' -AzureCli pwsh -AzureCliArguments @('-NoProfile', '-File', $script:Stub)
($result | ConvertFrom-Json).FederatedSubject | Should -Be 'repo:KofTwentyTwo/AppKit:environment:release'
(Get-Content -LiteralPath $script:LogPath -Raw) | Should -Not -Match '\b(create|delete|update)\b'
}
It 'rejects a different active tenant before touching Entra identities' `
{
$script:Fixture.TenantId = '44444444-4444-4444-4444-444444444444'
$script:Fixture | ConvertTo-Json | Set-Content -LiteralPath $script:FixturePath
{ & $script:IdentityScript -ConfigurationPath $script:ConfigurationPath -ExistingClientId '33333333-3333-3333-3333-333333333333' -AzureCli pwsh -AzureCliArguments @('-NoProfile', '-File', $script:Stub) } | Should -Throw '*tenant differs*'
(Get-Content -LiteralPath $script:LogPath -Raw) | Should -Not -Match '\bad app\b'
}
It 'rejects account-wide scope before granting any role' `
{
$script:Configuration.ProfileResourceId = '/subscriptions/8ef2479e-3346-4cba-93ad-101c6cd237a0/resourceGroups/rg-code-signing/providers/Microsoft.CodeSigning/codeSigningAccounts/kof22signing'
$script:Configuration | ConvertTo-Json | Set-Content -LiteralPath $script:ConfigurationPath
{ & $script:IdentityScript -ConfigurationPath $script:ConfigurationPath -AzureCli pwsh -AzureCliArguments @('-NoProfile', '-File', $script:Stub) } | Should -Throw '*outside the verified existing certificate profile*'
(Get-Content -LiteralPath $script:LogPath -Raw) | Should -Not -Match '\brole assignment\b'
}
It 'rejects another tenant as the default Graph context without switching it' `
{
$script:Fixture.DefaultTenantId = '44444444-4444-4444-4444-444444444444'
$script:Fixture | ConvertTo-Json | Set-Content -LiteralPath $script:FixturePath
{ & $script:IdentityScript -ConfigurationPath $script:ConfigurationPath -AzureCli pwsh -AzureCliArguments @('-NoProfile', '-File', $script:Stub) } | Should -Throw '*active CLI tenant differs*'
(Get-Content -LiteralPath $script:LogPath -Raw) | Should -Not -Match '\b(ad app|account set)\b'
}
It 'rejects a stale publisher subject before creating credentials' `
{
$script:Fixture.Subject = 'CN=Different Publisher'
$script:Fixture | ConvertTo-Json | Set-Content -LiteralPath $script:FixturePath
{ & $script:IdentityScript -ConfigurationPath $script:ConfigurationPath -AzureCli pwsh -AzureCliArguments @('-NoProfile', '-File', $script:Stub) } | Should -Throw '*no longer matches*'
(Get-Content -LiteralPath $script:LogPath -Raw) | Should -Not -Match '\bad app\b'
}
It 'preserves an existing credential whose name has different trust' `
{
$script:Fixture.TrustSubject = 'repo:KofTwentyTwo/OtherApp:environment:release'
$script:Fixture | ConvertTo-Json | Set-Content -LiteralPath $script:FixturePath
{ & $script:IdentityScript -ConfigurationPath $script:ConfigurationPath -ExistingClientId '33333333-3333-3333-3333-333333333333' -AzureCli pwsh -AzureCliArguments @('-NoProfile', '-File', $script:Stub) } | Should -Throw '*different trust*'
(Get-Content -LiteralPath $script:LogPath -Raw) | Should -Not -Match '\b(create|delete|update)\b'
}
}
+74
View File
@@ -0,0 +1,74 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Continue Pester commands so their Allman scriptblocks stay part of the call.
BeforeAll `
{
$script:CoverageGate = Join-Path $PSScriptRoot 'Assert-Coverage.ps1'
<#
.SYNOPSIS
Writes a synthetic report so tests can exercise release-gate exit codes.
#>
function Write-CoverageReport
{
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Directory,
[Parameter(Mandatory)] [ValidateRange(0, 1)] [double] $Rate
)
$rateText = $Rate.ToString([Globalization.CultureInfo]::InvariantCulture)
$secondLineHits = if ($Rate -eq 1) { 1 } else { 0 }
$report = @"
<coverage><packages><package name="Probe" line-rate="$rateText"><classes>
<class filename="Probe.cs"><lines><line number="1" hits="1" /><line number="2" hits="$secondLineHits" /></lines></class>
</classes></package></packages></coverage>
"@
Set-Content -LiteralPath (Join-Path $Directory 'coverage.cobertura.xml') -Value $report
}
}
Describe 'Required coverage gate' `
{
BeforeEach `
{
$script:ReportsDirectory = Join-Path $TestDrive ([guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $script:ReportsDirectory | Out-Null
}
It 'rejects a run that produced no report' `
{
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 1
$output | Should -Match 'No coverage.cobertura.xml'
}
It 'rejects an absent required package' `
{
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Missing 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 1
$output | Should -Match 'Missing.*not found'
}
It 'rejects coverage below the required percentage' `
{
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 1
$output | Should -Match 'Coverage gate FAILED'
}
It 'accepts full coverage of the required package' `
{
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 0
$output | Should -Match 'Coverage gate passed'
}
}
+39 -30
View File
@@ -1,3 +1,4 @@
#Requires -Version 7.4
<#
.SYNOPSIS
Fails when any named assembly is below the required line coverage.
@@ -13,51 +14,59 @@
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $ResultsDirectory,
[Parameter(Mandatory)] [string[]] $Packages,
[ValidateRange(0, 100)] [double] $MinimumPercent = 100
[Parameter(Mandatory)] [string] $ResultsDirectory,
[Parameter(Mandatory)] [string[]] $Packages,
[ValidateRange(0, 100)] [double] $MinimumPercent = 100
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# `pwsh -File` passes "A,B" as one string; accept that as well as a real array.
$Packages = @($Packages | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$files = @(Get-ChildItem -Path $ResultsDirectory -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue)
if ($files.Count -eq 0) {
Write-Error "No coverage.cobertura.xml found under '$ResultsDirectory'. Did the coverage collector run?" -ErrorAction Continue
exit 1
if ($files.Count -eq 0)
{
Write-Error "No coverage.cobertura.xml found under '$ResultsDirectory'. Did the coverage collector run?" -ErrorAction Continue
exit 1
}
$reports = foreach ($file in $files) { [xml](Get-Content -LiteralPath $file.FullName -Raw) }
$required = $MinimumPercent / 100
$failed = $false
foreach ($name in $Packages) {
$rates = foreach ($doc in $reports) {
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates) {
Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue
$failed = $true
continue
}
foreach ($name in $Packages)
{
$rates = foreach ($doc in $reports)
{
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates)
{
Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue
$failed = $true
continue
}
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Host ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent)
if ($best -lt $required) {
foreach ($doc in $reports) {
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
foreach ($class in @($package.classes.class)) {
$missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number }
if ($missed) { Write-Host " uncovered: $($class.filename) lines $($missed -join ', ')" }
}
}
Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue
$failed = $true
}
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Output ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent)
if ($best -lt $required)
{
foreach ($doc in $reports)
{
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
foreach ($class in @($package.classes.class))
{
$missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number }
if ($missed) { Write-Output " uncovered: $($class.filename) lines $($missed -join ', ')" }
}
}
Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue
$failed = $true
}
}
if ($failed) { exit 1 }
Write-Host "Coverage gate passed."
Write-Output "Coverage gate passed."
@@ -0,0 +1,79 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Reads the existing signing resource and exports verified, non-secret connection details.
.DESCRIPTION
Does not create or change Azure resources. Requires an authenticated Azure CLI.
#>
[CmdletBinding()]
param(
[string] $SubscriptionId = '8ef2479e-3346-4cba-93ad-101c6cd237a0',
[string] $ResourceGroup = 'rg-code-signing',
[string] $AccountName = 'kof22signing',
[string] $ProfileName = '',
[string] $OutputPath = 'artifacts/signing/verified-configuration.json',
[string] $AzureCli = 'az',
[string[]] $AzureCliArguments = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$azureExecutable = $AzureCli
$azureCommandPrefix = $AzureCliArguments
<#
.SYNOPSIS
Invokes the authenticated CLI and rejects failed or malformed responses.
#>
function Invoke-AzureJson
{
[CmdletBinding()]
param([Parameter(Mandatory)] [string[]] $Arguments)
$json = (& $azureExecutable @azureCommandPrefix @Arguments --output json --only-show-errors) -join "`n"
if($LASTEXITCODE -ne 0) { throw 'Azure inspection failed. Complete az login in this user context first.' }
return $json | ConvertFrom-Json
}
$subscription = Invoke-AzureJson -Arguments @('account', 'show', '--subscription', $SubscriptionId)
if($subscription.state -ne 'Enabled' -or $subscription.id -ne $SubscriptionId)
{
throw 'The requested signing subscription is not enabled or did not match.'
}
$tenantId = [guid]::Parse($subscription.tenantId).ToString()
if($tenantId -eq $SubscriptionId) { throw 'The tenant GUID must not be the subscription ID.' }
$resourceId = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.CodeSigning/codeSigningAccounts/$AccountName"
$apiVersion = '2025-10-13'
$account = Invoke-AzureJson -Arguments @('rest', '--method', 'GET', '--url', "https://management.azure.com${resourceId}?api-version=$apiVersion")
$profiles = Invoke-AzureJson -Arguments @('rest', '--method', 'GET', '--url', "https://management.azure.com${resourceId}/certificateProfiles?api-version=$apiVersion")
if($account.properties.provisioningState -ne 'Succeeded') { throw 'The existing signing account is not provisioned successfully.' }
$endpoint = [uri] $account.properties.accountUri
if($endpoint.Scheme -ne 'https' -or $endpoint.Host -notmatch '^[a-z0-9]+\.codesigning\.azure\.net$')
{
throw 'The account did not return a valid Artifact Signing regional endpoint.'
}
$certificateProfiles = @($profiles.value | Where-Object { (-not $ProfileName -or $_.name -eq $ProfileName) -and $_.properties.profileType -eq 'PublicTrust' -and $_.properties.status -eq 'Active' })
if($certificateProfiles.Count -ne 1) { throw 'Specify the existing profile name when there is not exactly one Active PublicTrust profile. Do not recreate profiles.' }
$subjects = @($certificateProfiles[0].properties.certificates | ForEach-Object subjectName | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
if($subjects.Count -ne 1)
{
throw 'The profile does not expose one verified publisher subject. Inspect its certificate records before configuring CI.'
}
$configuration = [ordered]@{
TenantId = $tenantId
SubscriptionId = $subscription.id
SubscriptionName = $subscription.name
AccountResourceId = $account.id
ProfileResourceId = $certificateProfiles[0].id
Location = $account.location
Endpoint = $endpoint.AbsoluteUri.TrimEnd('/')
CodeSigningAccountName = $account.name
CertificateProfileName = $certificateProfiles[0].name
PublisherSubject = $subjects[0]
VerifiedAtUtc = [DateTime]::UtcNow.ToString('O')
}
New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($OutputPath))) | Out-Null
$configuration | ConvertTo-Json | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM
Write-Output "Verified Azure signing configuration saved to $OutputPath. No resources were changed."
+46
View File
@@ -0,0 +1,46 @@
<#
.SYNOPSIS
Runs the pinned mutation tool against both UI-independent AppKit libraries.
.DESCRIPTION
Restores the repository-local tool and runs complete project mutations sequentially.
Writes HTML and JSON evidence below artifacts/mutation; surviving mutations must
be reviewed before a minor release. Tool failures cause the script to fail.
.PARAMETER Projects
Libraries to mutate. The default runs both core and updates.
#>
[CmdletBinding()]
param(
[ValidateSet('KofTwentyTwo.AppKit', 'KofTwentyTwo.AppKit.Updates')]
[string[]]$Projects = @('KofTwentyTwo.AppKit', 'KofTwentyTwo.AppKit.Updates')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$configuration = Join-Path $PSScriptRoot 'stryker-config.json'
$dotnetExecutable = (Get-Command dotnet -CommandType Application -ErrorAction Stop).Source
Push-Location $repositoryRoot
try
{
& $dotnetExecutable tool restore
if($LASTEXITCODE -ne 0)
{
throw "Mutation tool restore failed (exit $LASTEXITCODE)."
}
Set-Location (Join-Path $repositoryRoot 'tests/KofTwentyTwo.AppKit.Tests')
foreach($project in $Projects)
{
$output = Join-Path $repositoryRoot "artifacts/mutation/$project"
& $dotnetExecutable stryker --project "$project.csproj" --config-file $configuration --output $output --skip-version-check
if($LASTEXITCODE -ne 0)
{
throw "Mutation testing failed for $project (exit $LASTEXITCODE)."
}
}
}
finally
{
Pop-Location
}
+58
View File
@@ -0,0 +1,58 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Signs only catalogued owned binaries with Microsoft Artifact Signing and verifies them.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $ConfigurationPath,
[Parameter(Mandatory)] [string] $ManifestPath,
[Parameter(Mandatory)] [string] $DlibPath,
[string] $SignToolPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'WindowsSigning.psm1') -Force
if(-not $SignToolPath) { $SignToolPath = Get-WindowsSignTool }
if(-not (Test-Path -LiteralPath $DlibPath -PathType Leaf)) { throw 'Supply the installed x64 Azure.CodeSigning.Dlib.dll path.' }
$configuration = Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json
if([string]::IsNullOrWhiteSpace($configuration.PublisherSubject)) { throw 'The verified publisher subject is required.' }
$metadataPath = Join-Path ([IO.Path]::GetTempPath()) ('k22-signing-' + [guid]::NewGuid().ToString('N') + '.json')
try
{
@{
Endpoint = $configuration.Endpoint
CodeSigningAccountName = $configuration.CodeSigningAccountName
CertificateProfileName = $configuration.CertificateProfileName
ExcludeCredentials = @('EnvironmentCredential', 'WorkloadIdentityCredential', 'ManagedIdentityCredential', 'SharedTokenCacheCredential', 'VisualStudioCredential', 'VisualStudioCodeCredential', 'AzurePowerShellCredential', 'AzureDeveloperCliCredential', 'InteractiveBrowserCredential')
} | ConvertTo-Json | Set-Content -LiteralPath $metadataPath -Encoding utf8NoBOM
$entries = @(Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json | Where-Object owned)
if($entries.Count -eq 0) { throw 'No owned binaries were catalogued.' }
foreach($entry in $entries)
{
$signature = Get-AuthenticodeSignature -LiteralPath $entry.path
if($signature.Status -eq 'Valid')
{
# Preserve existing signatures. A different publisher fails verification below.
continue
}
if($signature.Status -ne 'NotSigned') { throw "Refusing to overwrite an invalid existing signature: $($entry.path)." }
if($PSCmdlet.ShouldProcess($entry.path, 'Sign with Azure Artifact Signing and RFC3161 timestamp'))
{
& $SignToolPath sign /v /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 /dlib $DlibPath /dmdf $metadataPath $entry.path
if($LASTEXITCODE -ne 0) { throw "Artifact Signing failed for $($entry.path)." }
}
}
if(-not $WhatIfPreference)
{
Assert-WindowsSigningManifest -ManifestPath $ManifestPath -ExpectedSubject $configuration.PublisherSubject -SignTool $SignToolPath
}
}
finally
{
if(Test-Path -LiteralPath $metadataPath) { Remove-Item -LiteralPath $metadataPath -Force }
}
+109
View File
@@ -0,0 +1,109 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Configures repository-specific OIDC trust and profile-scoped signing permission in the verified existing tenant.
.DESCRIPTION
Preserves other applications, federated credentials and assignments. Never creates signing accounts or profiles.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $ConfigurationPath,
[ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] [string] $Repository = 'KofTwentyTwo/AppKit',
[ValidatePattern('^[A-Za-z0-9_.-]+$')] [string] $Environment = 'release',
[string] $ExistingClientId = '',
[string] $AzureCli = 'az',
[string[]] $AzureCliArguments = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$azureExecutable = $AzureCli
$azureCommandPrefix = $AzureCliArguments
$configuration = Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json
<#
.SYNOPSIS
Executes an Azure administrative command without emitting tokens or credentials.
#>
function Invoke-AzureJson
{
[CmdletBinding()]
param([Parameter(Mandatory)] [string[]] $Arguments)
$json = (& $azureExecutable @azureCommandPrefix @Arguments --output json --only-show-errors) -join "`n"
if($LASTEXITCODE -ne 0) { throw "Azure command failed: $($Arguments[0..1] -join ' ')." }
if(-not [string]::IsNullOrWhiteSpace($json)) { return $json | ConvertFrom-Json }
}
$subscription = Invoke-AzureJson -Arguments @('account', 'show', '--subscription', $configuration.SubscriptionId)
if($subscription.tenantId -ne $configuration.TenantId) { throw 'Authenticated subscription tenant differs from the verified configuration.' }
$activeContext = Invoke-AzureJson -Arguments @('account', 'show')
if($activeContext.tenantId -ne $configuration.TenantId) { throw 'The active CLI tenant differs from the verified tenant. Select the intended administrative context before configuring Entra.' }
$scope = $configuration.ProfileResourceId
$expectedAccount = "/subscriptions/$($configuration.SubscriptionId)/resourceGroups/rg-code-signing/providers/Microsoft.CodeSigning/codeSigningAccounts/kof22signing"
if($scope -ne "$expectedAccount/certificateProfiles/$($configuration.CertificateProfileName)")
{
throw 'Refusing to grant signing permission outside the verified existing certificate profile.'
}
# Refresh the profile to reject stale or edited configuration before any mutation.
$deployed = Invoke-AzureJson -Arguments @('rest', '--method', 'GET', '--url', "https://management.azure.com${scope}?api-version=2025-10-13")
if($deployed.properties.status -ne 'Active' -or $deployed.properties.profileType -ne 'PublicTrust' -or $configuration.PublisherSubject -notin @($deployed.properties.certificates | ForEach-Object subjectName))
{
throw 'The active deployed publisher profile no longer matches the verified configuration.'
}
$roles = @(Invoke-AzureJson -Arguments @('role', 'definition', 'list', '--name', 'Artifact Signing Certificate Profile Signer', '--subscription', $configuration.SubscriptionId))
if($roles.Count -ne 1 -or $roles[0].roleType -ne 'BuiltInRole') { throw 'The built-in Artifact Signing Certificate Profile Signer role was not uniquely resolved.' }
$appName = 'github-' + $Repository.Replace('/', '-') + '-artifact-signing'
if($ExistingClientId)
{
$application = Invoke-AzureJson -Arguments @('ad', 'app', 'show', '--id', $ExistingClientId)
}
else
{
$applications = @(Invoke-AzureJson -Arguments @('ad', 'app', 'list', '--display-name', $appName) | Where-Object displayName -EQ $appName)
if($applications.Count -gt 1) { throw 'Multiple matching CI applications exist. Select a reviewed ExistingClientId.' }
$application = if($applications.Count -eq 1) { $applications[0] } else { $null }
if($null -eq $application)
{
if(-not $PSCmdlet.ShouldProcess($appName, 'Create repository-specific Entra CI application')) { return }
$application = Invoke-AzureJson -Arguments @('ad', 'app', 'create', '--display-name', $appName, '--sign-in-audience', 'AzureADMyOrg')
}
}
$principals = @(Invoke-AzureJson -Arguments @('ad', 'sp', 'list', '--filter', "appId eq '$($application.appId)'"))
if($principals.Count -gt 1) { throw 'Multiple service principals were returned.' }
$principal = if($principals.Count -eq 1) { $principals[0] } else { $null }
if($null -eq $principal)
{
if(-not $PSCmdlet.ShouldProcess($application.appId, 'Create CI service principal')) { return }
$principal = Invoke-AzureJson -Arguments @('ad', 'sp', 'create', '--id', $application.appId)
}
$subject = "repo:${Repository}:environment:${Environment}"
$issuer = 'https://token.actions.githubusercontent.com'
$audience = 'api://AzureADTokenExchange'
$federations = @(Invoke-AzureJson -Arguments @('ad', 'app', 'federated-credential', 'list', '--id', $application.id))
$matchingTrust = @($federations | Where-Object { $_.subject -eq $subject -and $_.issuer -eq $issuer -and @($_.audiences).Count -eq 1 -and $_.audiences[0] -eq $audience })
if($matchingTrust.Count -eq 0)
{
$credentialName = 'github-' + $Repository.Split('/')[1].ToLowerInvariant() + '-' + $Environment
if(@($federations | Where-Object name -EQ $credentialName).Count -gt 0) { throw 'An existing credential with this name has different trust. Review it; do not overwrite it.' }
if($PSCmdlet.ShouldProcess($subject, 'Add repository and protected-environment OIDC trust'))
{
$federationFile = Join-Path ([IO.Path]::GetTempPath()) ('k22-federation-' + [guid]::NewGuid().ToString('N') + '.json')
try
{
@{ name = $credentialName; issuer = $issuer; subject = $subject; audiences = @($audience) } |
ConvertTo-Json | Set-Content -LiteralPath $federationFile -Encoding utf8NoBOM
$null = Invoke-AzureJson -Arguments @('ad', 'app', 'federated-credential', 'create', '--id', $application.id, '--parameters', $federationFile)
}
finally { if(Test-Path -LiteralPath $federationFile) { Remove-Item -LiteralPath $federationFile -Force } }
}
}
$roleId = $roles[0].id
$assignments = @(Invoke-AzureJson -Arguments @('role', 'assignment', 'list', '--assignee', $principal.id, '--scope', $scope, '--include-inherited', '--subscription', $configuration.SubscriptionId))
if(@($assignments | Where-Object roleDefinitionId -EQ $roleId).Count -eq 0 -and $PSCmdlet.ShouldProcess($scope, 'Grant Certificate Profile Signer to this CI principal'))
{
$null = Invoke-AzureJson -Arguments @('role', 'assignment', 'create', '--assignee-object-id', $principal.id, '--assignee-principal-type', 'ServicePrincipal', '--role', $roleId, '--scope', $scope, '--subscription', $configuration.SubscriptionId)
}
[ordered]@{ Repository = $Repository; Environment = $Environment; TenantId = $configuration.TenantId; ClientId = $application.appId; PrincipalId = $principal.id; SubscriptionId = $configuration.SubscriptionId; ProfileScope = $scope; FederatedSubject = $subject } | ConvertTo-Json
+361
View File
@@ -0,0 +1,361 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
BeforeDiscovery `
{
Import-Module (Join-Path $PSScriptRoot 'WindowsSigning.psm1') -Force -Global
}
Describe 'Owned binary catalog' `
{
BeforeEach `
{
$script:Root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $script:Root | Out-Null
$script:Catalog = Join-Path $script:Root 'catalog.txt'
$script:Manifest = Join-Path $script:Root 'manifest.json'
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $script:Root 'Owned.dll')
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $script:Root 'Microsoft.dll')
}
It 'includes only explicit owned names and inventories other binaries' `
{
New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -PreserveOtherBinaries
@(Get-Content -LiteralPath $script:Catalog) | Should -HaveCount 1
Get-Content -LiteralPath $script:Catalog | Should -Be 'Owned.dll'
$entries = @(Get-Content -LiteralPath $script:Manifest -Raw | ConvertFrom-Json)
$entries | Should -HaveCount 2
@($entries | Where-Object owned).relativePath | Should -Be 'Owned.dll'
@($entries | Where-Object { -not $_.owned }).relativePath | Should -Be 'Microsoft.dll'
}
It 'rejects a missing required binary rather than silently skipping signing' `
{
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Missing.exe') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } |
Should -Throw '*No binary matches*'
}
It 'rejects parent traversal' `
{
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('../Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } |
Should -Throw '*relative paths*'
}
It 'supports WhatIf without writing signing inputs' `
{
New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -WhatIf
Test-Path -LiteralPath $script:Catalog | Should -BeFalse
Test-Path -LiteralPath $script:Manifest | Should -BeFalse
}
It 'rejects malformed owned PE input before writing a catalog' `
{
[IO.File]::WriteAllBytes((Join-Path $script:Root 'Owned.dll'), [byte[]]::new(128))
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Throw '*Invalid published PE binary: Owned.dll*'
Test-Path -LiteralPath $script:Catalog | Should -BeFalse
}
It 'rejects an already malformed preserved dependency' `
{
[IO.File]::WriteAllBytes((Join-Path $script:Root 'Microsoft.dll'), [byte[]]::new(128))
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -PreserveOtherBinaries } | Should -Throw '*Invalid published PE binary: Microsoft.dll*'
Test-Path -LiteralPath $script:Manifest | Should -BeFalse
}
It 'does not treat package-format signing targets as PE images' `
{
Set-Content -LiteralPath (Join-Path $script:Root 'Setup.msi') -Value 'package fixture; signature validation is a later gate'
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Setup.msi') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Not -Throw
Get-Content -LiteralPath $script:Catalog | Should -Be 'Setup.msi'
}
It 'ignores excluded binaries when dependency preservation is disabled' `
{
[IO.File]::WriteAllBytes((Join-Path $script:Root 'Microsoft.dll'), [byte[]]::new(128))
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Not -Throw
@(Get-Content -LiteralPath $script:Manifest -Raw | ConvertFrom-Json) | Should -HaveCount 1
}
}
Describe 'Signature and distributed-byte gates' `
{
InModuleScope WindowsSigning `
{
BeforeAll `
{
<#
.SYNOPSIS
Supplies deterministic SignTool results for rejection-path tests.
#>
function Invoke-ProbeSignTool
{
$global:LASTEXITCODE = $script:ProbeExitCode
return $script:ProbeOutput
}
}
BeforeEach `
{
$script:ProbeExitCode = 0
$script:ProbeOutput = 'Hash of file (sha256): 0123456789'
$script:ProbeSignature = [pscustomobject]@{
Status = 'Valid'
SignerCertificate = [pscustomobject]@{ Subject = 'CN=Verified Publisher'; SubjectName = [pscustomobject]@{ Name = 'CN=Verified Publisher' } }
TimeStamperCertificate = [pscustomobject]@{ Subject = 'CN=Timestamp Authority' }
}
Mock Get-AuthenticodeSignature { return $script:ProbeSignature }
}
It 'accepts a trusted timestamped SHA256 signature for the verified subject' `
{
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
}
It 'rejects a native verification failure' `
{
$script:ProbeExitCode = 1
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*verification failed*'
}
It 'rejects a different publisher' `
{
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Other Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Unexpected publisher*'
}
It 'rejects a missing timestamp' `
{
$script:ProbeSignature.TimeStamperCertificate = $null
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*timestamped*'
}
It 'rejects an untrusted certificate' `
{
$script:ProbeSignature.Status = 'NotTrusted'
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*timestamped*'
}
It 'rejects a non-SHA256 digest' `
{
$script:ProbeOutput = 'Hash of file (sha1): 0123456789'
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*SHA256*'
}
It 'rejects a SHA1 signature appended alongside a SHA256 signature' `
{
$script:ProbeOutput = "Hash of file (sha256): abc`nHash of file (sha1): def"
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*SHA256*'
}
It 'rejects modified third-party binaries' `
{
$root = Join-Path $TestDrive 'preserve'
New-Item -ItemType Directory -Path $root | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Microsoft.dll')
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
Set-Content -LiteralPath (Join-Path $root 'Microsoft.dll') -Value 'modified'
{ Assert-WindowsSigningManifest -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Third-party binary was modified*'
}
It 'refuses to catalog another publisher signature for replacement' `
{
$root = Join-Path $TestDrive 'other-publisher'
New-Item -ItemType Directory -Path $root | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
$script:ProbeSignature.SignerCertificate.SubjectName.Name = 'CN=Other Publisher'
{ New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath (Join-Path $root 'manifest.json') -ExpectedSubject 'CN=Verified Publisher' } | Should -Throw '*another publisher*'
Test-Path -LiteralPath (Join-Path $root 'catalog.txt') | Should -BeFalse
}
It 'rejects changed owned bytes in the final package' `
{
$root = Join-Path $TestDrive 'changed'
$assets = Join-Path $root 'assets'
$packed = Join-Path $root 'packed'
New-Item -ItemType Directory -Path $root, $assets, $packed | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
Set-Content -LiteralPath (Join-Path $packed 'Owned.dll') -Value 'modified after signing'
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg'))
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Distributed binary changed*'
}
It 'rejects an archive missing its required owned binary' `
{
$root = Join-Path $TestDrive 'missing'
$assets = Join-Path $root 'assets'
$packed = Join-Path $root 'packed'
New-Item -ItemType Directory -Path $root, $assets, $packed | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
Set-Content -LiteralPath (Join-Path $packed 'README.txt') -Value 'no binary'
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg'))
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Owned binary missing*'
}
It 'rejects a missing dependency from <Archive>' -TestCases @(
@{ Archive = 'Owned-Portable.zip'; Head = 'app' },
@{ Archive = 'Owned-1.0.0-full.nupkg'; Head = 'app' },
@{ Archive = 'Owned-cli-win-x64.zip'; Head = 'cli' }
) `
{
param($Archive, $Head)
$root = Join-Path $TestDrive ('missing-dependency-' + [guid]::NewGuid().ToString('N'))
$publish = Join-Path $root 'publish'
$assets = Join-Path $root 'assets'
$packed = Join-Path $root 'packed'
New-Item -ItemType Directory -Path "$publish/$Head", $assets, $packed | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/$Head/Owned.dll"
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/$Head/Microsoft.dll"
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $publish -Patterns @("$Head/Owned.dll") -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
Copy-Item -LiteralPath "$publish/$Head/Owned.dll" -Destination $packed
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets $Archive))
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Third-party binary missing*'
}
It 'allows only the default excluded foreign runtime helpers to be omitted' `
{
$root = Join-Path $TestDrive 'excluded-runtime'
$publish = Join-Path $root 'publish'
$assets = Join-Path $root 'assets'
$packed = Join-Path $root 'packed'
New-Item -ItemType Directory -Path "$publish/app", $assets, $packed | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll"
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Microsoft.dll"
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/createdump.exe"
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.vshost.exe"
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
Copy-Item -LiteralPath "$publish/app/Owned.dll", "$publish/app/Microsoft.dll" -Destination $packed
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned-Portable.zip'))
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
}
It 'rejects changed dependency bytes in the final archive' `
{
$root = Join-Path $TestDrive 'changed-dependency'
$publish = Join-Path $root 'publish'
$assets = Join-Path $root 'assets'
$packed = Join-Path $root 'packed'
New-Item -ItemType Directory -Path "$publish/app", $assets, $packed | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll"
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Microsoft.dll"
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
Copy-Item -LiteralPath "$publish/app/Owned.dll" -Destination $packed
Set-Content -LiteralPath (Join-Path $packed 'Microsoft.dll') -Value 'changed'
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned-Portable.zip'))
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Distributed binary changed*'
}
It 'accepts the exact signed bytes shipped in a package' `
{
$root = Join-Path $TestDrive 'valid'
$assets = Join-Path $root 'assets'
New-Item -ItemType Directory -Path $root, $assets | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
$packed = Join-Path $root 'packed'
New-Item -ItemType Directory -Path $packed | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'Owned.dll') -Destination $packed
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg'))
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
}
It 'verifies separate NuGet packages whose IDs share the core package prefix' `
{
$root = Join-Path $TestDrive 'prefix'
$assets = Join-Path $root 'assets'
New-Item -ItemType Directory -Path $root, $assets | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.Adapter.dll')
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll', 'Owned.Adapter.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
foreach($name in @('Owned', 'Owned.Adapter'))
{
$packed = Join-Path $root $name
New-Item -ItemType Directory -Path $packed | Out-Null
Copy-Item -LiteralPath (Join-Path $root "$name.dll") -Destination $packed
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets "$name.1.0.0.nupkg"))
}
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
}
It 'preserves same-name third-party satellite binaries in different culture directories' `
{
$root = Join-Path $TestDrive 'cultures'
$publish = Join-Path $root 'publish'
$assets = Join-Path $root 'assets'
New-Item -ItemType Directory -Path $assets, "$publish/app/en", "$publish/app/fr" | Out-Null
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll"
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/en/Library.resources.dll"
[IO.File]::AppendAllText("$publish/app/en/Library.resources.dll", 'English')
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/fr/Library.resources.dll"
[IO.File]::AppendAllText("$publish/app/fr/Library.resources.dll", 'French')
$manifest = Join-Path $root 'manifest.json'
New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
[IO.Compression.ZipFile]::CreateFromDirectory("$publish/app", (Join-Path $assets 'Owned-Portable.zip'))
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
}
}
}
Describe 'Velopack installer payload' `
{
BeforeEach `
{
$root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $root | Out-Null
$script:Installer = Join-Path $root 'Probe-Setup.exe'
$script:FullPackage = Join-Path $root 'Probe-full.nupkg'
$payload = [Text.Encoding]::UTF8.GetBytes('exact full package payload')
[IO.File]::WriteAllBytes($script:FullPackage, $payload)
Copy-Item -LiteralPath (Get-WindowsSignTool) -Destination $script:Installer
$stream = [IO.File]::Open($script:Installer, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite)
$reader = [Reflection.PortableExecutable.PEReader]::new($stream, [Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen)
try
{
$headerPosition = $reader.PEHeaders.SectionHeaders[0].PointerToRawData
$packageOffset = $stream.Length
$stream.Position = $headerPosition
$stream.Write([BitConverter]::GetBytes($packageOffset))
$stream.Write([BitConverter]::GetBytes([long] $payload.Length))
$stream.Write([Convert]::FromHexString('94F0B17B6893E02937EB34EF53AAE7D42B54F5707EF5D6F57854983E5E94ED7D'))
$stream.Position = $packageOffset
$stream.Write($payload)
}
finally
{
$reader.Dispose()
$stream.Dispose()
}
}
It 'accepts the exact embedded full-package bytes' `
{
{ Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Not -Throw
}
It 'rejects a different embedded payload of the same length' `
{
$bytes = [IO.File]::ReadAllBytes($script:FullPackage)
$bytes[0] = 0
[IO.File]::WriteAllBytes($script:FullPackage, $bytes)
{ Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Throw '*different full package*'
}
It 'rejects a different full-package length' `
{
[IO.File]::WriteAllText($script:FullPackage, 'short')
{ Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Throw '*length differs*'
}
}
+337
View File
@@ -0,0 +1,337 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
<#
.SYNOPSIS
Resolves the newest installed x64 Windows SDK SignTool.
#>
function Get-WindowsSignTool
{
[CmdletBinding()]
param()
$sdk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin'
$tools = @(Get-ChildItem -Path "$sdk/*/x64/signtool.exe" -File -ErrorAction SilentlyContinue |
Sort-Object { [version] $_.Directory.Parent.Name } -Descending)
if($tools.Count -eq 0) { throw 'Install the Windows SDK with x64 SignTool.' }
return $tools[0].FullName
}
<#
.SYNOPSIS
Builds an explicit signing catalog and records other binaries for preservation checks.
#>
function New-WindowsSigningCatalog
{
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $Root,
[Parameter(Mandatory)] [string[]] $Patterns,
[Parameter(Mandatory)] [string] $CatalogPath,
[Parameter(Mandatory)] [string] $ManifestPath,
[string] $ExpectedSubject = '',
[switch] $PreserveOtherBinaries
)
$rootPath = (Resolve-Path -LiteralPath $Root).Path
$binaries = @(Get-ChildItem -LiteralPath $rootPath -File -Recurse |
Where-Object Extension -In '.exe', '.dll', '.msi', '.msix')
$owned = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach($pattern in $Patterns)
{
$pattern = $pattern.Trim().Replace('\', '/')
if([string]::IsNullOrWhiteSpace($pattern) -or $pattern.Split('/') -contains '..' -or [IO.Path]::IsPathRooted($pattern))
{
throw "Signing patterns must be nonempty relative paths or file names: '$pattern'."
}
$catalogMatches = @($binaries | Where-Object {
$candidate = if($pattern.Contains('/')) { [IO.Path]::GetRelativePath($rootPath, $_.FullName).Replace('\', '/') } else { $_.Name }
$candidate -like $pattern
})
if($catalogMatches.Count -eq 0) { throw "No binary matches required signing pattern '$pattern'." }
foreach($file in $catalogMatches)
{
if($ExpectedSubject)
{
$expected = [Security.Cryptography.X509Certificates.X500DistinguishedName]::new($ExpectedSubject).Name
$existing = Get-AuthenticodeSignature -LiteralPath $file.FullName
if($null -ne $existing.SignerCertificate -and $existing.SignerCertificate.SubjectName.Name -cne $expected)
{
throw "Refusing to replace another publisher's signature on '$($file.FullName)'. Keep it outside the owned catalog."
}
if($existing.Status -notin 'Valid', 'NotSigned') { throw "Invalid existing signature on '$($file.FullName)'." }
}
$null = $owned.Add($file.FullName)
}
}
foreach($file in $binaries | Where-Object { ($PreserveOtherBinaries -or $owned.Contains($_.FullName)) -and $_.Extension -in '.exe', '.dll' })
{
$stream = [IO.File]::OpenRead($file.FullName)
try
{
$reader = [Reflection.PortableExecutable.PEReader]::new($stream)
try
{
if($null -eq $reader.PEHeaders.PEHeader) { throw 'Missing executable header.' }
}
finally { $reader.Dispose() }
}
catch { throw "Invalid published PE binary: $($file.Name). $($_.Exception.Message)" }
finally { $stream.Dispose() }
}
$entries = @($binaries | Where-Object { $PreserveOtherBinaries -or $owned.Contains($_.FullName) } | ForEach-Object {
[ordered]@{
path = $_.FullName
relativePath = [IO.Path]::GetRelativePath($rootPath, $_.FullName).Replace('\', '/')
owned = $owned.Contains($_.FullName)
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
}
})
$catalogDirectory = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($CatalogPath))
if(-not $PSCmdlet.ShouldProcess("$CatalogPath; $ManifestPath", 'Write signing catalog and preservation manifest')) { return }
New-Item -ItemType Directory -Force -Path $catalogDirectory | Out-Null
@($owned | Sort-Object | ForEach-Object { [IO.Path]::GetRelativePath($catalogDirectory, $_) }) |
Set-Content -LiteralPath $CatalogPath -Encoding utf8NoBOM
ConvertTo-Json -InputObject $entries -Depth 5 | Set-Content -LiteralPath $ManifestPath -Encoding utf8NoBOM
}
<#
.SYNOPSIS
Requires all Authenticode signatures to validate, with the configured publisher and a timestamp.
#>
function Assert-WindowsSignature
{
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string[]] $Files,
[Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ExpectedSubject,
[string] $SignTool = (Get-WindowsSignTool)
)
if($Files.Count -eq 0) { throw 'No shipped binaries were supplied for verification.' }
$expected = [Security.Cryptography.X509Certificates.X500DistinguishedName]::new($ExpectedSubject).Name
foreach($file in $Files)
{
$output = (& $SignTool verify /pa /all /v $file 2>&1) -join "`n"
Write-Output $output
if($LASTEXITCODE -ne 0) { throw "SignTool verification failed for '$file'." }
$signature = Get-AuthenticodeSignature -LiteralPath $file
if($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or $null -eq $signature.TimeStamperCertificate)
{
throw "Missing valid timestamped Authenticode signature on '$file'."
}
if($signature.SignerCertificate.SubjectName.Name -cne $expected)
{
throw "Unexpected publisher on '$file': $($signature.SignerCertificate.Subject)."
}
$digests = @([regex]::Matches($output, '(?i)Hash of file \(([^)]+)\)') | ForEach-Object { $_.Groups[1].Value })
if($digests.Count -eq 0 -or @($digests | Where-Object { $_ -ne 'sha256' }).Count -gt 0)
{
throw "SignTool did not report a SHA256 file digest for '$file'. Use the English Windows SDK verification output."
}
}
}
<#
.SYNOPSIS
Verifies signed catalog entries and checks that other binary bytes were preserved.
#>
function Assert-WindowsSigningManifest
{
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $ManifestPath,
[Parameter(Mandatory)] [string] $ExpectedSubject,
[string] $SignTool = (Get-WindowsSignTool)
)
$entries = @(Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json)
$owned = @($entries | Where-Object owned | ForEach-Object path)
Assert-WindowsSignature -Files $owned -ExpectedSubject $ExpectedSubject -SignTool $SignTool
foreach($entry in $entries | Where-Object { -not $_.owned })
{
if((Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash -ne $entry.sha256)
{
throw "Third-party binary was modified: $($entry.relativePath)."
}
}
}
<#
.SYNOPSIS
Checks signed binaries inside the final ZIP/NuGet assets and their bytes against the signed build.
#>
function Assert-WindowsDistribution
{
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Directory,
[Parameter(Mandatory)] [string] $ManifestPath,
[Parameter(Mandatory)] [string] $ExpectedSubject,
[string] $SignTool = (Get-WindowsSignTool)
)
$entries = @(Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json)
$owned = @($entries | Where-Object owned)
if($owned.Count -eq 0) { throw 'The signing manifest has no owned binaries.' }
$assets = @(Get-ChildItem -LiteralPath $Directory -File)
$archives = @($assets | Where-Object { $_.Extension -in '.zip', '.nupkg' -and $_.Name -notlike '*-delta.nupkg' })
if($archives.Count -eq 0) { throw 'No final ZIP or full NuGet package was produced.' }
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach($archive in $archives)
{
$scratch = Join-Path ([IO.Path]::GetTempPath()) ('k22-signing-' + [guid]::NewGuid().ToString('N'))
try
{
[IO.Compression.ZipFile]::ExtractToDirectory($archive.FullName, $scratch)
$files = @(Get-ChildItem -LiteralPath $scratch -File -Recurse)
$archiveSeen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach($entry in $entries)
{
$name = [IO.Path]::GetFileName($entry.path)
$packageRelative = if($entry.relativePath.StartsWith('app/') -or $entry.relativePath.StartsWith('cli/')) { $entry.relativePath.Substring(4) } else { $name }
$copies = @($files | Where-Object {
$relative = [IO.Path]::GetRelativePath($scratch, $_.FullName).Replace('\', '/')
$_.Name -eq $name -and ($relative -eq $packageRelative -or $relative.EndsWith('/' + $packageRelative, [StringComparison]::OrdinalIgnoreCase))
})
foreach($copy in $copies)
{
$hash = (Get-FileHash -LiteralPath $copy.FullName -Algorithm SHA256).Hash
$expectedHash = if($entry.owned) { (Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash } else { $entry.sha256 }
if($hash -ne $expectedHash)
{
# The portable archive also carries a generated launcher at its root.
if($entry.owned -and $copy.Extension -eq '.exe' -and $copy.DirectoryName -eq $scratch -and $archive.Name -like '*-Portable.zip')
{
Assert-WindowsSignature -Files @($copy.FullName) -ExpectedSubject $ExpectedSubject -SignTool $SignTool
continue
}
throw "Distributed binary changed in $($archive.Name): $name."
}
if($entry.owned)
{
Assert-WindowsSignature -Files @($copy.FullName) -ExpectedSubject $ExpectedSubject -SignTool $SignTool
$null = $seen.Add($entry.path)
}
$null = $archiveSeen.Add($entry.path)
}
}
$required = @($entries | Where-Object {
if($archive.Name -like '*-cli-*.zip') { return $_.relativePath.StartsWith('cli/') }
if($archive.Name -like '*-Portable.zip' -or $archive.Name -like '*-full.nupkg')
{
# Match Velopack's default exclusions for runtime helpers.
if(-not $_.owned -and $_.relativePath -match '(?:^|/)createdump[^/]*|\.vshost\.') { return $false }
return $_.relativePath.StartsWith('app/')
}
$packageId = [regex]::Escape([IO.Path]::GetFileNameWithoutExtension($_.path))
return $_.owned -and $archive.Name -match "^$packageId\.[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?\.nupkg$"
})
foreach($entry in $required)
{
if(-not $archiveSeen.Contains($entry.path))
{
$kind = if($entry.owned) { 'Owned' } else { 'Third-party' }
throw "$kind binary missing from $($archive.Name): $($entry.relativePath)."
}
}
# Velopack creates these branded helpers before its signing hook.
$updates = @($files | Where-Object { $_.Name -in 'Update.exe', 'Squirrel.exe' -or $_.Name -like '*_ExecutionStub.exe' } | ForEach-Object FullName)
if($updates.Count -gt 0) { Assert-WindowsSignature -Files $updates -ExpectedSubject $ExpectedSubject -SignTool $SignTool }
}
finally
{
$temporaryRoot = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath([IO.Path]::GetTempPath()))
if([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($scratch)) -ne $temporaryRoot -or [IO.Path]::GetFileName($scratch) -notlike 'k22-signing-*')
{
throw 'Refusing to remove a directory outside the signing scratch location.'
}
if(Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force }
}
}
foreach($entry in $owned)
{
if(-not $seen.Contains($entry.path)) { throw "Owned binary is missing from distributed archives: $($entry.relativePath)." }
}
$installers = @($assets | Where-Object Extension -In '.exe', '.msi', '.msix' | ForEach-Object FullName)
if($installers.Count -gt 0) { Assert-WindowsSignature -Files $installers -ExpectedSubject $ExpectedSubject -SignTool $SignTool }
foreach($setup in $assets | Where-Object Name -Like '*-Setup.exe')
{
$fullPackages = @($assets | Where-Object Name -Like '*-full.nupkg')
if($fullPackages.Count -ne 1) { throw 'Require one current full package to verify the installer payload.' }
Assert-VelopackInstallerPayload -Installer $setup.FullName -FullPackage $fullPackages[0].FullName
}
}
<#
.SYNOPSIS
Requires the signed Velopack installer to embed the exact verified full update package.
#>
function Assert-VelopackInstallerPayload
{
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Installer,
[Parameter(Mandatory)] [string] $FullPackage
)
# Velopack 1.2.161 SetupBundle: two little-endian Int64s followed by this marker.
$marker = '94F0B17B6893E02937EB34EF53AAE7D42B54F5707EF5D6F57854983E5E94ED7D'
$stream = [IO.File]::OpenRead($Installer)
$reader = [Reflection.PortableExecutable.PEReader]::new($stream, [Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen)
$hasher = [Security.Cryptography.IncrementalHash]::CreateHash([Security.Cryptography.HashAlgorithmName]::SHA256)
try
{
$offset = 0L
$length = 0L
$headersFound = 0
foreach($section in $reader.PEHeaders.SectionHeaders)
{
[byte[]] $bytes = $reader.GetSectionData($section.VirtualAddress).GetContent()
$position = [Convert]::ToHexString($bytes).IndexOf($marker, [StringComparison]::Ordinal)
if($position -lt 32 -or $position % 2 -ne 0) { continue }
$headersFound++
$offset = [BitConverter]::ToInt64($bytes, $position / 2 - 16)
$length = [BitConverter]::ToInt64($bytes, $position / 2 - 8)
}
if($headersFound -ne 1 -or $offset -le 0 -or $length -le 0 -or $offset -gt $stream.Length -or $length -gt $stream.Length - $offset)
{
throw 'The Velopack installer bundle header is missing, ambiguous or outside the file.'
}
if($length -ne (Get-Item -LiteralPath $FullPackage).Length) { throw 'Installer payload length differs from the full package.' }
$stream.Position = $offset
$buffer = [byte[]]::new(1048576)
$remaining = $length
while($remaining -gt 0)
{
$count = $stream.Read($buffer, 0, [int] [math]::Min($buffer.Length, $remaining))
if($count -eq 0) { throw 'Unexpected end of the installer payload.' }
$hasher.AppendData($buffer, 0, $count)
$remaining -= $count
}
$hash = [Convert]::ToHexString($hasher.GetHashAndReset())
if($hash -ne (Get-FileHash -LiteralPath $FullPackage -Algorithm SHA256).Hash) { throw 'Installer embeds a different full package.' }
}
finally
{
$hasher.Dispose()
$reader.Dispose()
$stream.Dispose()
}
}
Export-ModuleMember -Function Get-WindowsSignTool, New-WindowsSigningCatalog, Assert-WindowsSignature, Assert-WindowsSigningManifest, Assert-WindowsDistribution, Assert-VelopackInstallerPayload
+9
View File
@@ -0,0 +1,9 @@
{
"stryker-config": {
"reporters": ["cleartext", "html", "json"],
"mutation-level": "Standard",
"concurrency": 2,
"break-on-initial-test-failure": true,
"coverage-analysis": "perTest"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Coverage contract for the 100% gate: every hand-written line in the framework-free
packages counts. Excluded, with intent:
- GeneratedCodeAttribute: source-generated regexes and JSON contexts.
- GeneratedCodeAttribute: source-generated regexes, JSON contexts and log methods.
- ExcludeFromCodeCoverageAttribute: thin pass-throughs to Velopack that need an
installed app or the network (each carries a justification at its declaration).
The WinUI and WPF packages are not measured: their views need a live compositor and
+27 -8
View File
@@ -6,9 +6,14 @@ Pushing a protected `v*` tag on `main` runs [`.github/workflows/release.yml`](..
which calls the shared `release-nuget.yml` workflow in KofTwentyTwo/standards. All four
packages always release together under one version.
> **Status:** the shared workflows are pinned to a commit on KofTwentyTwo/standards
> `main` while the standards are pre-release; they move to a release tag's commit once
> KofTwentyTwo/standards publishes `v1.0.0` (Dependabot proposes the update).
The shared workflows are pinned to the published standards `v0.1.2` commit.
Dependabot proposes updates; review the workflow changes before moving the pin.
New releases currently stop in preflight until the prepared shared Artifact Signing
builder is published and enabled. Follow [release signing](release-signing.md) to
verify the existing Azure profile, configure repository-specific OIDC and move the
builder pin to its published release. No unsigned fallback is permitted for the next
release; the already published 0.1.0 assets remain immutable.
## Versioning
@@ -30,10 +35,14 @@ are `<prefix>-dev`. Bump the prefix right after each release, in a pull request.
## What a release does
The shared workflow (SLSA Build L3: the build definition lives in KofTwentyTwo/standards,
not here):
The caller first verifies that the tagged commit belongs to `main`, then reruns
build, unit tests, coverage, formatting, UI tests, security scans, CodeQL and script
checks, plus mutation-test runs for both UI-independent libraries. All must pass
before publication. Mutation HTML/JSON evidence is retained for 30 days by the
`mutation` workflow, which also supports manual runs. The shared workflow builds the
release separately (SLSA Build L3: its build definition lives in KofTwentyTwo/standards):
1. Validates the tag and re-runs every gate that guards `main`: locked restore,
1. Validates strict SemVer and repeats locked restore,
zero-warning build, unit tests, and the 100% coverage gate.
2. Packs the four packages and their symbol packages.
3. Generates a CycloneDX 1.6 SBOM per package (Syft, from `packages.lock.json`) and
@@ -55,8 +64,8 @@ nuget.org through **trusted publishing** (OIDC; no API key is stored anywhere).
- Workflow file: `release.yml`
- Environment: `release`
2. **Repository variable** `NUGET_USER` (Settings → Secrets and variables → Actions →
Variables): your nuget.org profile name. Without it the `publish` job is skipped and
the release still succeeds on GitHub.
Variables): your nuget.org profile name. Missing configuration fails preflight
before a GitHub release is created.
3. **Environment** `release` (Settings → Environments) with a deployment rule that allows
only tags matching `v*`.
@@ -66,6 +75,16 @@ nuget.org through **trusted publishing** (OIDC; no API key is stored anywhere).
`pwsh ../standards/tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/AppKit -LocalPath .`
2. For a minor or major release, open a release checklist issue from the standards
template and complete it (security assessment, threat model review, scan results).
Update [CHANGELOG.md](../CHANGELOG.md) with user-facing changes, upgrade impact,
and advisory IDs for any publicly known vulnerability fixed. Include or link those
notes in the GitHub release; generated PR lists alone may omit important changes.
Run `./build/Invoke-MutationTests.ps1` before a minor release, then attach or link
both library reports and the disposition of every surviving/uncovered core
mutation. New tests or explicit decisions are required by K22-TEST-11; a tool
exit code alone does not prove test adequacy. The release caller repeats the runs
for the tagged commit. No numeric mutation-score requirement has been adopted.
Use [the latest mutation audit](testing/mutation-testing.md) as a starting point;
confirm its survivor decisions still match the release code.
3. Tag `main` and push the tag:
```powershell
+206
View File
@@ -0,0 +1,206 @@
# Windows release signing
AppKit uses the existing KofTwentyTwo Azure Artifact Signing service. Do not create
another tenant, signing account, publisher validation or certificate profile. Never
export a PFX or put the administrative account's password in CI.
## Current implementation and evidence
The owner reports completed publisher validation and an active Public Trust profile.
The subscription observed during setup is `8ef2479e-3346-4cba-93ad-101c6cd237a0`,
account `kof22signing`, resource group `rg-code-signing`. The tenant GUID, actual
region/endpoint, final profile name and publisher subject still require a successful
authenticated resource lookup. `releases` and East US were setup expectations.
Local tooling and the shared builder changes are prepared. The latter are isolated
on standards branch `fix/artifact-signing-appkit`, in this checkout's ignored
`artifacts/standards-signing/` worktree. The published standards v0.1.2 NuGet builder
does not support signing, so AppKit's next release deliberately fails preflight until
the reviewed shared change is published and the caller is updated. Do not sign or
repack packages after their checksums, attestations or immutable release are created.
Verified locally: 26 signing/package checks and six administrative-boundary tests;
Microsoft's `Microsoft.ArtifactSigning.Client` 1.0.146 author/repository signatures;
real `signtool verify /pa /all /v` on the installed Microsoft-signed SignTool; rejection
of an unsigned AppKit DLL. **No AppKit Azure signature or signed release is proven.**
The attempted CLI device login ended with authorization pending. No Azure identities,
credentials, roles or GitHub signing variables were changed.
## Reference from gclo
The inspected gclo release environment uses tenant
`4ebeb0ad-5782-487a-a1b4-5080255774cf`, the subscription above, account
`kof22signing`, profile `releases` and `https://eus.codesigning.azure.net`.
These are observed configuration values, not a deployed-resource lookup. Its
publisher check uses `James Maes`; obtain the full certificate subject from the
profile before configuring AppKit. Do not copy gclo's client ID without checking
AppKit's federated trust and profile-scoped signer permission.
[Standards v1.0.0](https://github.com/KofTwentyTwo/standards/releases/tag/v1.0.0)
publishes gclo's Velopack signing fix: preserve valid existing signatures, allow
more time for signing, and invoke SignTool/dlib through `--signTemplate`.
Its NuGet builder still lacks the signing inputs AppKit needs. The isolated
AppKit builder additionally selects explicit owned files, refuses invalid
existing signatures, verifies each newly signed helper, and checks final
publisher, timestamp, archive bytes and embedded installer payload before
publication. Missing third-party dependencies now fail archive verification,
with only Velopack's default runtime-helper exclusions allowed.
Five regression tests exercise the actual signing wrapper without credentials.
The inspected [gclo beta.3 run](https://github.com/KofTwentyTwo/gclo/actions/runs/38087007507)
passed OIDC, signing, packaging and its pre-publication signature check. The run
ultimately failed because its separate post-publication verification job could
not read the publisher variable. Require all signing variables in the protected
release environment before building; do not defer this check until publication.
Successful gclo signing is reference evidence; it does not prove AppKit's
identity, binaries or final release. Keep signing access confined to trusted
release jobs and retain the required real AppKit verification below.
## Verify the existing resource
Sign in interactively with `koftwentytwo@outlook.com`, using the existing directory
domain only to select the tenant:
```powershell
az login --tenant koftwentytwooutlook.onmicrosoft.com
./build/Get-ArtifactSigningConfiguration.ps1
```
If using the isolated CLI installed during this work:
```powershell
$cliPrefix = @('tool', 'run', '--python', '3.13', '--prerelease', 'allow', '--with', 'setuptools<81', '--from', 'azure-cli==2.91.0', 'az')
uv @cliPrefix login --tenant koftwentytwooutlook.onmicrosoft.com
./build/Get-ArtifactSigningConfiguration.ps1 -AzureCli uv -AzureCliArguments $cliPrefix
```
The read-only script retrieves the subscription's tenant GUID and deployed account
URI/location, selects the existing Active PublicTrust profile and reads
`properties.certificates[].subjectName`. It saves non-secret evidence in
`artifacts/signing/verified-configuration.json`. With multiple active profiles, pass
`-ProfileName <existing-name>`. Missing or ambiguous certificate subjects stop setup;
inspect the existing profile's certificate records rather than inventing a subject.
## Configure repository-specific authentication
An administrator needs permission to manage the selected Entra application and
assign the built-in signer role at the existing certificate profile. Owner or
Contributor alone is insufficient to sign.
```powershell
$configPath = 'artifacts/signing/verified-configuration.json'
./build/New-ArtifactSigningIdentity.ps1 -ConfigurationPath $configPath -WhatIf
$identity = ./build/New-ArtifactSigningIdentity.ps1 -ConfigurationPath $configPath | ConvertFrom-Json
```
Add `-AzureCli uv -AzureCliArguments $cliPrefix` when using the isolated CLI. To reuse
a reviewed existing CI application, pass `-ExistingClientId <application-client-id>`.
The script creates a repository-specific application/service principal only if
needed, adds trust for `repo:KofTwentyTwo/AppKit:environment:release`, and assigns
**Artifact Signing Certificate Profile Signer** at the certificate profile scope.
It preserves other applications, credentials and assignments, and rejects conflicting
trust. Coordinate application reuse with other app maintainers before running it.
Each generated application needs its own repository subject and reviewed identity.
Repeat with `-Repository Owner/NewApp`; do not give arbitrary template consumers
access to KofTwentyTwo's signing service.
Set these non-secret variables in GitHub's protected **release** environment, using
the verified configuration and returned identity:
```powershell
$config = Get-Content $configPath -Raw | ConvertFrom-Json
$variables = @{
AZURE_TENANT_ID = $config.TenantId
AZURE_CLIENT_ID = $identity.ClientId
AZURE_SUBSCRIPTION_ID = $config.SubscriptionId
ARTIFACT_SIGNING_ENDPOINT = $config.Endpoint
ARTIFACT_SIGNING_ACCOUNT = $config.CodeSigningAccountName
ARTIFACT_SIGNING_PROFILE = $config.CertificateProfileName
ARTIFACT_SIGNING_SUBJECT = $config.PublisherSubject
}
foreach($entry in $variables.GetEnumerator())
{
gh variable set $entry.Key --repo KofTwentyTwo/AppKit --env release --body $entry.Value
}
```
AppKit's environment currently permits only `v*` tags. Retain that protection and
the tag-on-main gate. Signing jobs use `contents: read`, `id-token: write` and the
official `azure/login` v3 and `azure/artifact-signing-action` v2 pinned to immutable
commits. Disable every other credential type; use the Azure CLI credential established
by login. Pull requests never receive signing access.
## Enable the shared release builders
Review and publish the prepared standards change through its normal release process.
Update AppKit's shared builder pin to that **published release's full commit SHA**
and version comment. Add the following to the existing release job's `with:` block:
```yaml
sign: true
signing-files: |
src/KofTwentyTwo.AppKit/bin/Release/net10.0/KofTwentyTwo.AppKit.dll
src/KofTwentyTwo.AppKit.Updates/bin/Release/net10.0/KofTwentyTwo.AppKit.Updates.dll
src/KofTwentyTwo.AppKit.WinUI/bin/Release/net10.0-windows10.0.19041.0/KofTwentyTwo.AppKit.WinUI.dll
src/KofTwentyTwo.AppKit.Wpf/bin/Release/net10.0-windows/KofTwentyTwo.AppKit.Wpf.dll
```
The NuGet builder signs these DLLs before `pack --no-build`, then extracts each final
package and verifies the shipped DLLs and their exact bytes. NuGet ZIP containers and
symbol packages are not Authenticode targets; retain NuGet integrity/provenance checks.
The Velopack builder accepts required owned names or publish-relative patterns.
For the starter's CLI variant, the catalog includes `app/Kof22App.exe`,
`app/Kof22App.dll`, `app/Kof22App.Core.dll`, the three consumed AppKit DLLs and
`cli/kof22-app.exe`; adapt these to the generated names. The no-CLI variant omits
the last entry. Preserve all other binary bytes and signatures. The catalog gate
rejects malformed owned or preserved `.exe`/`.dll` inputs before catalog creation;
unchanged hashes alone cannot prove that the source was executable. MSI/MSIX follow
their format-specific package/signature checks. The packaging hook
signs branded Squirrel/execution stubs and the final installer after bundling; final
verification covers portable/CLI ZIPs, full update packages, installer signatures and
the installer's embedded full-package hash. Manifests, checksums and attestations
follow signing. Delta upgrade reconstruction still needs the installed-channel test
tracked in AppKit #11. Future MSIX manifests must use the verified publisher subject.
## Local signing and the required real test
Install Microsoft's [Artifact Signing Client Tools](https://learn.microsoft.com/azure/artifact-signing/how-to-signing-integrations)
(`winget install -e --id Microsoft.Azure.ArtifactSigningClientTools`), the x64 Windows
SDK SignTool, .NET 8 runtime and required Visual C++ runtime. An already verified client
copy is at `artifacts/signing/client-tools/1.0.146/bin/x64/Azure.CodeSigning.Dlib.dll`.
The discovered SignTool is `C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe`.
Use `Get-WindowsSignTool` to resolve other machines' SDK versions.
The local identity must have the profile signer role. If the administrative user lacks
it, an authorized administrator must assign that exact built-in role at the verified
profile; subscription Owner access is not a substitute. Ensure an authenticated `az`
is available on PATH for the client's `AzureCliCredential`.
```powershell
Import-Module ./build/WindowsSigning.psm1 -Force
# Build Release first; enumerate owned DLLs using the four exact paths above.
$owned = @('src/KofTwentyTwo.AppKit/bin/Release/net10.0/KofTwentyTwo.AppKit.dll')
New-WindowsSigningCatalog -Root . -Patterns $owned -CatalogPath artifacts/signing/catalog.txt -ManifestPath artifacts/signing/manifest.json -ExpectedSubject $config.PublisherSubject
./build/Invoke-WindowsSigning.ps1 -ConfigurationPath $configPath -ManifestPath artifacts/signing/manifest.json -DlibPath artifacts/signing/client-tools/1.0.146/bin/x64/Azure.CodeSigning.Dlib.dll
```
The client metadata contains `Endpoint`, `CodeSigningAccountName` and
`CertificateProfileName`. Signing always uses `/fd SHA256`,
`/tr http://timestamp.acs.microsoft.com` and `/td SHA256`. Existing valid signatures
are preserved; invalid existing signatures fail.
For the representative test, sign all four Release DLLs, pack without rebuilding,
then run `Assert-WindowsDistribution` against the final NuGet directory. For apps,
run the signed Velopack path and verify the final installer and extracted binaries:
```powershell
signtool verify /pa /all /v <artifact.exe-or-dll>
Assert-WindowsDistribution -Directory artifacts/nuget -ManifestPath artifacts/signing/manifest.json -ExpectedSubject $config.PublisherSubject
```
Record the deployed profile subject, SignTool trust/timestamp output, final asset
hashes and CI run before closing AppKit #12 or EX-0002. An Active profile alone is
not proof that signing works. Pushes, merges and release publication require their
existing owner authorization.
+61
View File
@@ -0,0 +1,61 @@
# OpenSSF Best Practices assessment
Prepared 2026-10-10 for AppKit's first release. This is an evidence worksheet, not
a badge or completed certification. Use the current
[passing criteria](https://www.bestpractices.dev/en/criteria/0) when submitting.
## Register the project
The owner signs in at [bestpractices.dev](https://www.bestpractices.dev/en/projects/new)
and registers **AppKit** with repository and project URL
`https://github.com/KofTwentyTwo/AppKit`, language C#, and MIT license. No credential
belongs in this repository. After registration, keep the public project URL here
and add the site's generated badge to README. An in-progress entry is not a passing
badge; mark only evidenced criteria as met.
## Evidence worksheet
`Ready` means repository evidence is available after these changes merge. `Owner`
requires a truthful maintainer attestation or interpretation of applicability;
it must not be inferred from documentation alone. `Pending` needs more work.
Criterion names below match the passing-level form; check its details for each entry.
| Criteria | Status | Evidence / next step |
| --- | --- | --- |
| `description_good`, `interact`, `documentation_basics`, `documentation_interface` | Ready | [README](../../README.md), package READMEs, XML API comments, runnable WinUI/WPF samples. |
| `contribution`, `contribution_requirements`, `english` | Ready | [CONTRIBUTING](../../CONTRIBUTING.md), PR template, English issues and documentation. |
| `floss_license`, `floss_license_osi`, `license_location` | Ready | Root MIT [LICENSE](../../LICENSE) and SPDX headers; NuGet manifests carry MIT. |
| `sites_https`, `discussion` | Ready | HTTPS GitHub repository, issues, PR discussions, and nuget.org downloads. |
| `maintained` | Ready | First release 2026-10-10, active maintainer and support policy. The badge criterion differs from Scorecard's 90-day heuristic. |
| `repo_public`, `repo_track`, `repo_interim`, `repo_distributed` | Ready | Public Git repository, signed history, topic branches and PRs between tagged releases. |
| `version_unique`, `version_semver`, `version_tags` | Ready | Signed `v0.1.0`, four versioned packages, [release procedure](../RELEASING.md). |
| `release_notes` | Pending | [CHANGELOG](../../CHANGELOG.md) describes the initial release. Publish it, then link/include those notes in the v0.1.0 GitHub release; existing generated notes mainly describe standards adoption. |
| `release_notes_vulns` | Owner | Confirm whether any publicly known vulnerability was fixed; select N/A only if there were none. Include advisory IDs in future affected releases. |
| `report_process`, `report_tracker`, `report_archive` | Ready | Public GitHub Issues, bug template, searchable issue responses. |
| `report_responses`, `enhancement_responses` | Owner | No 2–12 month reporting history exists yet. Apply the form's details honestly; maintain timely acknowledgements instead of inventing historical reports. |
| `vulnerability_report_process`, `vulnerability_report_private` | Ready | [SECURITY](../../SECURITY.md) links GitHub private vulnerability reporting. |
| `vulnerability_report_response` | Owner | Policy targets seven-day acknowledgement. Confirm actual report history; a target is not proof of every response time. |
| `build`, `build_common_tools` | Ready | .NET SDK and MSBuild; locked restore and zero-warning build documented and run in CI. |
| `build_floss_tools` | Owner | .NET/MSBuild are open source, but full WinUI builds require Windows SDK/platform tooling. Verify licenses and applicability; explain a justified SHOULD exception if necessary. |
| `test`, `test_invocation`, `test_most`, `test_continuous_integration` | Ready | xUnit/Coverlet/FlaUI, standard `dotnet test`, required CI, 100% core/update line coverage. UI coverage is separately scoped by ADR-0003. |
| `test_policy`, `tests_are_added`, `tests_documented_added` | Ready | CONTRIBUTING requires behavior/regression tests; recent source/tests and generated-input properties provide evidence. |
| `warnings`, `warnings_fixed`, `warnings_strict` | Ready | TreatWarningsAsErrors, .editorconfig, .NET and Meziantou analyzers, format gate; local solution build has zero warnings. |
| `know_secure_design`, `know_common_errors` | Owner | Read the criteria details and personally attest knowledge. [Threat model](threat-model.md) documents boundaries, hostile inputs, dependency risks and mitigations, but cannot attest a person's knowledge. |
| `crypto_published`, `crypto_call`, `crypto_working`, `crypto_weaknesses` | Owner | AppKit delegates secrets to Windows Credential Manager and delivery to HTTPS/Velopack. Review upstream algorithms/defaults; it implements no cryptographic algorithm. |
| `crypto_floss`, `crypto_keylength`, `crypto_pfs`, `crypto_random` | Owner | Clarify applicability of OS-managed DPAPI and TLS. The Windows credential backend is platform-specific; do not assert a FLOSS equivalent or configurable key lengths without evidence. |
| `crypto_password_storage` | Owner | AppKit stores host-provided secrets, not passwords to authenticate external users. Review the criterion and use N/A with that explanation if applicable. |
| `delivery_mitm`, `delivery_unsigned` | Ready | GitHub/NuGet HTTPS, signed tags, SHA256SUMS and verified build provenance. README documents attestation verification. |
| `vulnerabilities_fixed_60_days`, `vulnerabilities_critical_fixed` | Owner | Confirm all publicly known runtime vulnerabilities and their age; public alerts here concern process controls. Recheck advisories/dependency scans before submitting. |
| `no_leaked_credentials` | Owner | Secret scanning and read-only workflow tokens provide controls. Review scan results/history before attesting no valid credential leak. |
| `static_analysis`, `static_analysis_common_vulnerabilities`, `static_analysis_often` | Ready | Required CodeQL security-extended C#/Actions scans, .NET/Meziantou analyzers, security workflow on PRs/main and weekly. |
| `static_analysis_fixed` | Owner | Confirm no unresolved medium/high exploitable static findings. Record Scorecard process exceptions separately in [scorecard.md](scorecard.md). |
| `dynamic_analysis`, `dynamic_analysis_enable_assertions` | Ready | xUnit/FlaUI and four FsCheck properties with 500 generated cases each; assertions, shrinking, and replay seed on failure. |
| `dynamic_analysis_unsafe` | Owner | Managed C# production code includes native Windows interop and Velopack dependencies. Review whether this criterion applies to project-produced code before choosing N/A. |
| `dynamic_analysis_fixed` | Owner | Confirm findings and remediation history; a green test run alone cannot establish the absence of every vulnerability. |
## Submission and verification
Complete the owner rows, resolve pending release notes, and provide public evidence
URLs from merged `main` for the form's required explanations. After submitting,
record the public project ID and actual badge level here. Rerun Scorecard on `main`
and inspect alert #5. Do not disable the check or claim passing status from this draft.
+71
View File
@@ -0,0 +1,71 @@
# Scorecard findings and review controls
AppKit retains the single-maintainer policy in
[K22-REPO-20](https://github.com/KofTwentyTwo/standards/blob/main/standards/repository.md)
and [K22-SDLC-12 / EX-0001](https://github.com/KofTwentyTwo/standards/blob/main/policies/sdlc.md).
OpenSSF Scorecard's independent human-review criteria exceed that policy. The
following findings are tracked separately from exploitable code vulnerabilities.
## Findings assessed on 2026-10-10
Baseline: `main` at `839fb72bec464223133b405df85bb01608d25360`.
Sources: GitHub alert instances, the live `protect-main` ruleset, PR #2 review history,
repository files, and [Scorecard check definitions](https://github.com/ossf/scorecard/blob/main/docs/checks.md).
| Alert | Evidence and disposition | Revisit when |
| --- | --- | --- |
| [#4 Code-Review](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/4) | No independent approval on either assessed changeset. Accepted single-maintainer limitation; AI reviews cannot satisfy Scorecard. Require an acknowledged automated review plus CodeQL instead. PR #2 had no automated review record in the inspected comments/reviews; fix the process for future PRs. | An independent maintainer joins; check review records on every PR. |
| [#3 Maintained](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/3) | Created 2026-10-09; not archived. Scorecard requires over 90 days of history. Continue maintenance; no artificial commits or scanner suppression. | 2027-01-08, after 90 days have elapsed. |
| [#1 Branch-Protection](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/1) | Main requires PRs, resolved threads, 11 passing checks, up-to-date branches, signed commits, linear history, and no force pushes/deletion or bypass actors. Zero human approvals and disabled CODEOWNERS/last-push approval match the one-maintainer exception. | An independent maintainer joins; enable one independent approval and last-push approval, then assess CODEOWNERS enforcement. |
| [#2 Fuzzing](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/2) | Add four real FsCheck properties to the existing CI unit suite. The pinned action uses Scorecard v5.5.0, whose [C# detector](https://github.com/ossf/scorecard/blob/v5.5.0/checks/raw/fuzzing.go) recognizes `using FsCheck.Xunit;`. Local tests do not close the remote alert; rerun Scorecard after merge and verify detection. | The implementation reaches main, or parsing/input contracts change. |
| [#5 CII-Best-Practices](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/5) | No registered badge project. The [assessment](openssf-best-practices.md) prepares evidence and identifies owner attestations. Do not claim a badge until the public project entry exists. | The owner registers and completes the assessment. |
No alert was dismissed or scanner setting weakened while preparing these changes.
If accepting the two review limitations in GitHub, use `won't fix` with the
single-maintainer exception and a link to this published document; do not label them
false positives. Any age-warning dismissal must say that maintenance cannot yet be
assessed and include the revisit date. Keep fuzzing and badge alerts open until verified.
## Recording an automated review
Run Codex, Claude, or Copilot against the PR diff and relevant source/tests at its
current head SHA. Resolve or answer every finding, rerun affected checks, and repeat
the review after changes. The maintainer must read the result. As `KofTwentyTwo`, post
a separate PR comment using the following format; replace placeholders with real evidence:
```markdown
<!-- appkit-automated-review:FULL_40_CHARACTER_HEAD_SHA -->
Tool: Codex
Findings: none
Summary: Describe the actual review scope, outcome, and any resolved findings.
Maintainer acknowledgement: I read the review and resolved or answered every finding.
```
Use `Findings: resolved` after addressing findings, or `Findings: unresolved` to block
the check. Include findings and their resolution below the summary. The latest
maintainer-owned record for that SHA controls the result. Editing or deleting comments
reruns validation. Other contributors and bots cannot attest for the maintainer.
The gate validates the record's author, commit, result, and acknowledgement. It
cannot prove the quality of the AI review or replace independent human review.
Keep each `Tool`, `Findings`, and `Summary` field unique; conflicting/duplicate fields
fail validation rather than allowing an old passing result to hide unresolved findings.
## Enabling the required checks
1. Review, test, and merge the bootstrap PR normally under the existing 11 checks;
record its automated review manually. The new comment workflow is unavailable
until it exists on the default branch.
2. On a subsequent PR, post a genuine review record and verify `review / automated`
is attached to the **PR head**, succeeds for its SHA, and fails for an incomplete
or unresolved record. `review / tests` must also pass.
3. Add both contexts to `protect-main`'s required checks with the GitHub Actions
integration, preserving all existing rules and the empty bypass list.
4. Verify a new commit cannot merge with the previous record. It has no passing
review check until a new record is posted; do not reuse the old SHA.
The workflow runs only on PR comment events and checks out `github.workflow_sha`,
the trusted default-branch workflow commit. It never checks out a PR head, executes
comment text, uses a PAT, or shares caches/artifacts with untrusted code. Its write
permission is limited to publishing check runs. Adding another maintainer requires
updating the record-author policy and the tests as well as branch protections.
+45 -5
View File
@@ -71,7 +71,7 @@ flowchart LR
| T3 | Update flow | Denial of service | A failing or slow update check crashes or blocks the app | Medium | Medium | Every update member is guarded and returns errors as values; the check runs after startup | Mitigated |
| T4 | Secrets | Information disclosure | A token leaks through logs, settings, or memory | Medium | High | Secrets only in the Credential Manager; never in settings or logs; the pinned plaintext buffer is cleared after writing | Mitigated |
| T5 | settings.json | Tampering | A crafted settings file sets out-of-range values or crashes parsing | Low | Low | Source-generated JSON parsing inside a catch-all; values sanitized; failure means defaults | Mitigated |
| T6 | Log files | Tampering / denial of service | A huge or crafted log file hangs the log viewer | Low | Low | The viewer reads only the last 2,000 lines; the entry regex has a match timeout | Mitigated |
| T6 | Log files | Tampering / denial of service | A huge or crafted log file hangs the log viewer | Low | Low | Each refresh reads at most the last 1 MiB plus a four-byte encoding header, retains at most 2,000 lines, and discloses truncation; the byte limit also bounds a single line and the entry regex has a match timeout | Mitigated |
| T7 | UI thread | Denial of service | An exception escaping an async handler kills the app | Medium | Medium | Crash net logs and handles dispatcher exceptions; one-dialog-at-a-time guard prevents the stowed-exception crash | Mitigated |
| T8 | Data folder | Elevation of privilege | `<ID>_DATA_DIR` points the app at another location | Low | Low | The variable is set by the same user; the app gains no rights it did not have | Accepted |
| T9 | Dependencies | Tampering | A compromised package in AppKit's graph | Low | High | Central versions, lock files with locked restore, dependency review, Trivy and OSV-Scanner in CI, Dependabot with a cooldown | Mitigated |
@@ -81,13 +81,53 @@ flowchart LR
- **T8:** another process running as the same user can redirect or read AppKit's data;
defending against the user's own processes is outside a desktop library's reach.
- **Unsigned binaries until code signing is available:** apps built on AppKit ship
without an Authenticode signature until the KofTwentyTwo signing identity is validated
([exception EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002));
provenance attestations and checksums remain verifiable.
- **Previously unsigned releases:** AppKit 0.1.0 was published under
[EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002).
The owner reports completed Azure publisher validation and an active Public Trust
profile. CI authentication, profile-scoped permission and signed-byte evidence
remain outstanding. The release caller now blocks new publication until the shared
signing builder and verified configuration are enabled. See
[release signing](../release-signing.md); retain the exception until actual signing
and final-artifact verification succeed. Existing provenance remains verifiable.
Signing trust is restricted to `repo:KofTwentyTwo/AppKit:environment:release`, whose
deployment policy permits `v*` tags. Signing grants use the certificate profile scope;
the administrative account is never a CI identity. Only explicitly catalogued owned
DLLs are signed before packing. Verification checks trust, publisher, timestamps and
SHA256 digests, then checks the exact DLL bytes inside NuGet assets before checksums
and provenance. The prepared app builder additionally preserves dependency bytes and
verifies Velopack helpers and the installer's embedded full-package payload. These
controls are prepared locally; Azure and live CI proof are still required.
Owned and preserved `.exe`/`.dll` headers are parsed before creating signing
catalogs. An already corrupt source could otherwise retain its hash through every
archive comparison. Regression tests reject malformed owned/dependency inputs,
retain distinct culture-specific payloads and leave excluded/non-PE package
formats to their applicable checks. This gate does not establish corruption's
cause or replace final signature and payload verification.
## 8. Review log
Generated-input regression coverage for T5 and T6 lives in
`tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs`. Four FsCheck properties each
run 500 cases in the normal unit suite, checking repair invariants, arbitrary JSON
and UTF-8 bytes, persistence, and bounded log tails. These bounded tests do not
replace native dependency testing. `LoggingTests.cs` additionally verifies a 128 MiB
file, a 4 MiB single line, smaller explicit byte budgets, empty input, and BOM-marked
UTF-8/UTF-16/UTF-32 tails in both byte orders. Refreshes seek directly to a bounded
window; they do not scan older content. A partial first visible line is identified
by the viewer's truncation notice.
`StructuredLoggingTests.cs` checks original templates, typed fields, JSON escaping,
full exception details, and concurrent asynchronous scope isolation. Structured
events use the standard .NET logging abstraction with an explicit scalar/JSON value
policy; the adapter does not reflect over arbitrary objects. Logging templates,
fields and scopes remain subject to the prohibition on secrets and personal data.
Repository change controls and their single-maintainer limitation are assessed in
[scorecard.md](scorecard.md). The automated review-record check validates an
acknowledged review of the current PR commit; it does not replace human review.
| Date | Version | Reviewer | Changes |
| --- | --- | --- | --- |
| 2026-10-09 | 0.1.0-dev | James Maes | Initial model |
+57
View File
@@ -0,0 +1,57 @@
# Library mutation testing
Run `./build/Invoke-MutationTests.ps1` on Windows with the repository's .NET SDK.
The local tool manifest pins Stryker.NET 5.0.0 (Apache-2.0, development only).
Both complete library projects use Standard mutations, two workers and per-test
coverage. No repository mutation exclusions or score overrides are configured.
Initial test failures and tool errors fail the run. HTML/JSON reports remain under
`artifacts/mutation/`; the release workflow repeats runs and retains evidence.
Review survivors before a minor release under K22-TEST-11. Successful execution
does not approve survivors. Default score colors are advisory, with no adopted
numeric gate. Reassess decisions whenever the affected behavior changes.
## Audit on 2026-10-10
| Library | Initial score | Final score | Killed | Survived | Compile errors | Ignored |
| --- | --- | --- | --- | --- | --- | --- |
| Core | 90.17% | 96.27% | 284 | 11 | 38 | 75 |
| Updates | 77.92% | 89.61% | 69 | 8 | 2 | 34 |
Final runs have no uncovered mutations or timeouts. Core's final run used 160
tests; updates used 161 after an additional backend-context case. The complete
unit suite passes 161 tests and retains 100% core/updates line coverage. Compilation
errors cannot exercise behavior; ignored counts come from Stryker's redundancy
filters and existing coverage exclusions, including the live GitHub/Velopack adapter.
Scores are calculated from tested mutations, not every generated mutation.
New or strengthened tests killed 27 former survivors: settings sanitized before
writing, shared-writer log reads, removed-folder recovery, disabled logger state,
single-line warnings, identity diagnostics/version metadata, empty secret keys,
native error context, backend caching, conflicting update outcomes, and asynchronous
UI/backend context affinity. These additions verify behavior without changing
production code solely to improve a score.
## Remaining survivor decisions
IDs identify mutations in this run, not permanent identifiers. All 19 are accounted
for below. These are technical review decisions; release-owner review is still required.
| File and IDs | Decision and evidence |
| --- | --- |
| `AppInfo.cs`: 4 | Equivalent under supported construction: required `Id` always invokes the validated initializer and overwrites the backing field before use. |
| `ArgbColor.cs`: 49, 51, 53 | Equivalent: `value` is unsigned, so `>>` and `>>>` produce identical channel bytes. |
| `BuildVersion.cs`: 84 | Equivalent: slicing a nine-character hash to nine characters returns the same hash; changing `>` to `>=` has no effect. |
| `LogTail.cs`: 260 | Equivalent for supported encodings: twice the preamble length is divisible by the code-unit width, so the changed modular alignment is identical. |
| `LogTail.cs`: 282 | Equivalent: UTF-32's `byteOrderMark` flag changes emitted preambles, not decoding; this reader skips the known four-byte header explicitly. |
| `CredentialManagerVault.cs`: 334, 335, 352 | Retain cleanup. Wiping the pinned plaintext array, freeing its pin and freeing native credentials are security/resource requirements. Functional native tests cannot prove memory wiping or allocator release. Reviewed the unconditional `finally` cleanup, including error paths; record this as a mutation-test limitation, not equivalence. Do not remove these calls. |
| `CredentialManagerVault.cs`: 346 | Retain the defensive OR guard for either zero pointer or zero size. Real Credential Manager tests exercise valid native buffers, not fabricated malformed pointers. Native-boundary limitation; not a claim that AND is safe. |
| `UpdateCoordinator.cs`: 12, 19, 24, 27, 41, 53 | Tail awaits perform no later UI work on their continuation; changing capture has no observable effect in these paths. Retain explicit capture for consistent UI orchestration. Asynchronous tests kill the capture changes before subsequent prompts/downloads. |
| `VelopackUpdateService.cs`: 85 | Tool-repaired equivalence: deleting the getter's catch-body return still returns null through Stryker's injected `return default(string)`. The throwing-backend test proves the public fail-closed behavior. |
| `VelopackUpdateService.cs`: 92 | Retain context-free check processing. This continuation only updates private state and returns its result; caller continuation affinity is controlled by the caller's await. Backend application and UI prompt affinity are explicitly tested. |
Stryker's return repair is implemented by its
[EndingReturnEngine](https://github.com/stryker-mutator/stryker-net/blob/dotnet-stryker%405.0.0/src/Stryker.Core/Stryker.Core/Instrumentation/EndingReturnEngine.cs).
See the [configuration reference](https://stryker-mutator.io/docs/stryker-net/configuration/)
for filter and score semantics. Keep exact-run reports with the release checklist;
rerun at the reviewed/tagged commit before claiming release acceptance.
+53
View File
@@ -0,0 +1,53 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
// JavaScript-only repository tools use the shared K22-CODE-TS layout and lint
// rules. TypeScript type checking does not apply to these CommonJS scripts.
export default [
{ ignores: ["artifacts/**", "node_modules/**"] },
js.configs.recommended,
{
files: [".github/scripts/*.cjs", "eslint.config.mjs"],
plugins: { "@stylistic": stylistic },
rules: {
"@stylistic/indent": ["error", 3, { SwitchCase: 1 }],
"@stylistic/brace-style": ["error", "allman", { allowSingleLine: false }],
"@stylistic/keyword-spacing": ["error", {
before: true,
after: true,
overrides: {
if: { after: false },
for: { after: false },
while: { after: false },
switch: { after: false },
catch: { after: false },
},
}],
"@stylistic/no-multiple-empty-lines": ["error", { max: 3, maxBOF: 0, maxEOF: 0 }],
"@stylistic/lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }],
"@stylistic/semi": ["error", "always"],
"@stylistic/quotes": ["error", "double", { avoidEscape: true }],
"@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/eol-last": ["error", "always"],
"@stylistic/no-tabs": "error",
"@stylistic/no-trailing-spaces": "error",
"@stylistic/operator-linebreak": ["error", "before"],
"@stylistic/dot-location": ["error", "property"],
curly: ["error", "all"],
eqeqeq: ["error", "always"],
"no-console": "error",
"no-warning-comments": ["error", { terms: ["todo", "fixme"], location: "start" }],
camelcase: ["error", { properties: "never" }],
},
},
{
files: [".github/scripts/*.cjs"],
languageOptions: {
sourceType: "commonjs",
globals: { require: "readonly", module: "readonly" },
},
},
];
+1074
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "appkit-repository-tools",
"private": true,
"description": "Lint and test the repository review gate; no shipped application dependencies.",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"lint": "eslint .github/scripts eslint.config.mjs --max-warnings 0",
"test": "node --test .github/scripts/review-gate.test.cjs"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"eslint": "10.12.0"
},
"packageManager": "npm@11.16.0"
}
+11 -1
View File
@@ -5,6 +5,7 @@
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml;
@@ -18,6 +19,9 @@ public partial class App : Application
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths);
/// <summary>The shared structured logger, including scopes.</summary>
internal static ILogger Logger { get; } = Log.AsLogger();
/// <summary>Installs the crash net and applies the saved theme; WinUI allows setting the application theme only here, before any window exists.</summary>
@@ -34,8 +38,14 @@ public partial class App : Application
/// <summary>Logs the start and opens the main window.</summary>
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Log.Info($"{SampleApp.Info.DisplayName} started.");
LogStarted(Logger, SampleApp.Info.DisplayName);
_window = new MainWindow();
_window.Activate();
}
/// <summary>Records app identity as a field without formatting it into the template.</summary>
[LoggerMessage(100, LogLevel.Information, "{AppName} started.")]
private static partial void LogStarted(ILogger logger, string appName);
}
+15 -2
View File
@@ -6,6 +6,7 @@
using KofTwentyTwo.AppKit.Settings;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml;
@@ -104,7 +105,7 @@ public sealed partial class MainWindow : Window
/// <summary>Writes an INFO entry, to show logging.</summary>
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
App.Log.Info("The user pressed 'Write a log entry'.");
LogUserAction(App.Logger);
StatusText.Text = "Wrote an INFO entry.";
}
@@ -119,13 +120,25 @@ public sealed partial class MainWindow : Window
}
catch(InvalidOperationException ex)
{
App.Log.Error("Sample operation failed.", ex);
LogSampleFailure(App.Logger, ex);
StatusText.Text = "Wrote an ERROR entry with its stack trace.";
}
}
/// <summary>Records the sample button interaction.</summary>
[LoggerMessage(101, LogLevel.Information, "The user pressed 'Write a log entry'.")]
private static partial void LogUserAction(ILogger logger);
/// <summary>Records a handled sample failure with its full exception.</summary>
[LoggerMessage(102, LogLevel.Error, "Sample operation failed.")]
private static partial void LogSampleFailure(ILogger logger, Exception exception);
/// <summary>Throws on purpose, to show that the crash net logs the exception and the app keeps running.</summary>
private void Throw_Click(object sender, RoutedEventArgs e)
{
+3 -2
View File
@@ -3,8 +3,8 @@
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Updates;
using Microsoft.UI.Dispatching;
using Velopack;
namespace AppKit.Sample.WinUI;
@@ -19,7 +19,8 @@ public static class Program
[STAThread]
private static void Main()
{
VelopackStartup.Run();
// vpk checks the entry assembly for this direct call before packaging.
VelopackApp.Build().Run();
// The remainder mirrors the XAML-generated Main.
WinRT.ComWrappersSupport.InitializeComWrappers();
+22 -5
View File
@@ -32,6 +32,11 @@
"Microsoft.WindowsAppSDK.WinUI": "2.3.9"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
@@ -146,23 +151,35 @@
"contentHash": "hyJB4UlpAi19Xr9AXzu2NuagKC4lPfHObNMEAA0HmqFz2rX7wKgzeYzO/jM/eBHDhnUGFFEjk5cOoJaxqg5J4A=="
},
"koftwentytwo.appkit": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )"
}
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit": "[0.2.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"koftwentytwo.appkit.winui": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit.Updates": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit": "[0.2.0-dev, )",
"KofTwentyTwo.AppKit.Updates": "[0.2.0-dev, )",
"Microsoft.WindowsAppSDK": "[2.5.1, )"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
@@ -243,4 +260,4 @@
}
}
}
}
}
+15 -2
View File
@@ -7,6 +7,7 @@ using System.Windows;
using System.Windows.Controls;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.Wpf;
using Microsoft.Extensions.Logging;
namespace AppKit.Sample.Wpf;
@@ -94,7 +95,7 @@ public partial class MainWindow : Window
/// <summary>Writes an INFO entry, to show logging.</summary>
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
Program.Log.Info("The user pressed 'Write a log entry'.");
LogUserAction(Program.Logger);
StatusText.Text = "Wrote an INFO entry.";
}
@@ -109,13 +110,25 @@ public partial class MainWindow : Window
}
catch(InvalidOperationException ex)
{
Program.Log.Error("Sample operation failed.", ex);
LogSampleFailure(Program.Logger, ex);
StatusText.Text = "Wrote an ERROR entry with its stack trace.";
}
}
/// <summary>Records the sample button interaction.</summary>
[LoggerMessage(101, LogLevel.Information, "The user pressed 'Write a log entry'.")]
private static partial void LogUserAction(ILogger logger);
/// <summary>Records a handled sample failure with its full exception.</summary>
[LoggerMessage(102, LogLevel.Error, "Sample operation failed.")]
private static partial void LogSampleFailure(ILogger logger, Exception exception);
/// <summary>Throws on purpose, to show that the crash net logs the exception and the app keeps running.</summary>
private void Throw_Click(object sender, RoutedEventArgs e)
{
+15 -4
View File
@@ -5,30 +5,41 @@
using System.Windows;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.Wpf;
using Microsoft.Extensions.Logging;
using Velopack;
namespace AppKit.Sample.Wpf;
/// <summary>Entry point: Velopack hooks first, then the WPF application.</summary>
public static class Program
public static partial class Program
{
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths);
/// <summary>The shared structured logger, including scopes.</summary>
internal static ILogger Logger { get; } = Log.AsLogger();
/// <summary>Runs Velopack&apos;s install and update hooks first, then creates the WPF application with AppKit&apos;s crash net and theme and shows the main window.</summary>
[STAThread]
private static void Main()
{
VelopackStartup.Run();
// vpk checks the entry assembly for this direct call before packaging.
VelopackApp.Build().Run();
var app = new Application { ShutdownMode = ShutdownMode.OnMainWindowClose };
WpfShell.InstallCrashNet(app, () => Log);
WpfShell.ApplyTheme(app, SampleApp.SettingsStore.Load().ThemeKind);
Log.Info($"{SampleApp.Info.DisplayName} (WPF) started.");
LogStarted(Logger, SampleApp.Info.DisplayName);
app.Run(new MainWindow());
}
/// <summary>Records startup with the app identity as a field.</summary>
[LoggerMessage(100, LogLevel.Information, "{AppName} (WPF) started.")]
private static partial void LogStarted(ILogger logger, string appName);
}
+22 -5
View File
@@ -8,21 +8,38 @@
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"koftwentytwo.appkit": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )"
}
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit": "[0.2.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"koftwentytwo.appkit.wpf": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit.Updates": "[0.1.0-dev, )"
"KofTwentyTwo.AppKit": "[0.2.0-dev, )",
"KofTwentyTwo.AppKit.Updates": "[0.2.0-dev, )"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Velopack": {
@@ -33,4 +50,4 @@
}
}
}
}
}
@@ -5,6 +5,7 @@
using KofTwentyTwo.AppKit.Interaction;
using KofTwentyTwo.AppKit.Logging;
using Microsoft.Extensions.Logging;
namespace KofTwentyTwo.AppKit.Updates;
@@ -14,12 +15,12 @@ namespace KofTwentyTwo.AppKit.Updates;
/// <see cref="IUserPrompter"/> so WinUI and WPF apps behave identically and the logic is
/// unit-tested.
/// </summary>
public sealed class UpdateCoordinator
public sealed partial class UpdateCoordinator
{
private readonly IUpdateService _updates;
private readonly IUserPrompter _prompter;
private readonly string _appName;
private readonly IActivityLog _log;
private readonly ILogger _log;
@@ -36,7 +37,7 @@ public sealed class UpdateCoordinator
_updates = updates;
_prompter = prompter;
_appName = appName;
_log = log ?? NullActivityLog.Instance;
_log = (log ?? NullActivityLog.Instance).AsLogger();
}
@@ -56,7 +57,7 @@ public sealed class UpdateCoordinator
UpdateCheckResult result = await _updates.CheckAsync(cancellationToken).ConfigureAwait(true);
if(result.Error is not null)
{
_log.Warning($"Update check failed: {result.Error}");
LogCheckFailed(_log, result.Error);
await _prompter.ShowMessageAsync(UpdateText.CheckTitle, UpdateText.CheckFailed(result.Error)).ConfigureAwait(true);
return;
}
@@ -86,13 +87,13 @@ public sealed class UpdateCoordinator
UpdateCheckResult result = await _updates.CheckAsync(cancellationToken).ConfigureAwait(true);
if(result.Error is not null)
{
_log.Warning($"Startup update check failed: {result.Error}");
LogStartupCheckFailed(_log, result.Error);
return;
}
if(result.AvailableVersion is not null)
{
_log.Info($"Update available: v{result.AvailableVersion}.");
LogUpdateAvailable(_log, result.AvailableVersion);
await OfferAsync(result.AvailableVersion, cancellationToken).ConfigureAwait(true);
}
}
@@ -109,14 +110,44 @@ public sealed class UpdateCoordinator
return;
}
_log.Info($"Installing update v{version}.");
LogInstalling(_log, version);
// On success this exits the process to restart into the new version, so
// reaching the lines below means the update did not go through.
string? error = await _updates.DownloadAndApplyAsync(null, cancellationToken).ConfigureAwait(true);
if(error is not null)
{
_log.Error($"Update to v{version} failed: {error}");
LogInstallFailed(_log, version, error);
await _prompter.ShowMessageAsync(UpdateText.FailedTitle, error).ConfigureAwait(true);
}
}
/// <summary>Records a failed interactive check with its separate error field.</summary>
[LoggerMessage(1, LogLevel.Warning, "Update check failed: {Error}")]
private static partial void LogCheckFailed(ILogger logger, string error);
/// <summary>Records a failed startup check.</summary>
[LoggerMessage(2, LogLevel.Warning, "Startup update check failed: {Error}")]
private static partial void LogStartupCheckFailed(ILogger logger, string error);
/// <summary>Records the available version independently of the rendered text.</summary>
[LoggerMessage(3, LogLevel.Information, "Update available: v{Version}.")]
private static partial void LogUpdateAvailable(ILogger logger, string version);
/// <summary>Records which version is about to be installed.</summary>
[LoggerMessage(4, LogLevel.Information, "Installing update v{Version}.")]
private static partial void LogInstalling(ILogger logger, string version);
/// <summary>Records the target version and installation failure.</summary>
[LoggerMessage(5, LogLevel.Error, "Update to v{Version} failed: {Error}")]
private static partial void LogInstallFailed(ILogger logger, string version, string error);
}
@@ -14,6 +14,11 @@ namespace KofTwentyTwo.AppKit.Updates;
/// the app's exe with special arguments; this handles them and may exit the process.
/// Outside those moments (F5, loose builds, MSIX) it does nothing.
/// </summary>
/// <remarks>
/// Retained for existing callers. For applications packaged by vpk, call
/// <c>VelopackApp.Build().Run()</c> directly in the entry assembly: vpk's static
/// startup verification cannot recognize this indirect wrapper.
/// </remarks>
public static class VelopackStartup
{
/// <summary>
@@ -14,9 +14,26 @@
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"koftwentytwo.appkit": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
}
}
}
}
}
+7 -1
View File
@@ -5,6 +5,7 @@
using KofTwentyTwo.AppKit.Logging;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Media;
@@ -26,6 +27,7 @@ public sealed partial class LogWindow : Window
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = s_refreshInterval };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7 };
private readonly TextBlock _limitNotice = new() { Text = "Older log content omitted", VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed };
private readonly TextBlock _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsTextSelectionEnabled = true, TextWrapping = TextWrapping.NoWrap };
private readonly ScrollViewer _scroll = new() { HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true };
@@ -42,6 +44,8 @@ public sealed partial class LogWindow : Window
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
AutomationProperties.SetAutomationId(_logText, "LogText");
AutomationProperties.SetAutomationId(_limitNotice, "LogTruncationNotice");
var copy = new Button { Content = "Copy" };
var openFolder = new Button { Content = "Open logs folder" };
@@ -60,6 +64,7 @@ public sealed partial class LogWindow : Window
toolbar.Children.Add(_errorsOnly);
toolbar.Children.Add(copy);
toolbar.Children.Add(openFolder);
toolbar.Children.Add(_limitNotice);
toolbar.Children.Add(_pathText);
_scroll.Content = _logText;
@@ -146,8 +151,9 @@ public sealed partial class LogWindow : Window
_pathText.Text = _log.CurrentLogFilePath; // the day can roll over while the window is open
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
_limitNotice.Visibility = snapshot.IsTruncated ? Visibility.Visible : Visibility.Collapsed;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly);
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated);
_scroll.UpdateLayout();
_scroll.ChangeView(null, _scroll.ScrollableHeight, null, disableAnimation: true);
+9 -2
View File
@@ -5,13 +5,14 @@
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>App-level wiring every WinUI app repeats: crash net and theme.</summary>
public static class WinUIShell
public static partial class WinUIShell
{
/// <summary>
/// Last-resort net: an exception escaping an async void event handler is rethrown
@@ -30,7 +31,7 @@ public static class WinUIShell
e.Handled = true;
try
{
log()?.Error($"Unhandled UI exception: {e.Message}", e.Exception);
LogUnhandledException((log() ?? NullActivityLog.Instance).AsLogger(), e.Exception, e.Message);
}
catch
{
@@ -42,6 +43,12 @@ public static class WinUIShell
/// <summary>Retains exception details and a separate error field for the crash net.</summary>
[LoggerMessage(10, LogLevel.Error, "Unhandled UI exception: {Error}")]
private static partial void LogUnhandledException(ILogger logger, Exception exception, string error);
/// <summary>
/// Applies the startup theme. Only valid in the App constructor, before any window
/// exists; for live changes use <see cref="ApplyTheme(FrameworkElement, AppTheme)"/>.
@@ -32,6 +32,11 @@
"Microsoft.WindowsAppSDK.WinUI": "2.3.9"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
@@ -146,15 +151,27 @@
"contentHash": "hyJB4UlpAi19Xr9AXzu2NuagKC4lPfHObNMEAA0HmqFz2rX7wKgzeYzO/jM/eBHDhnUGFFEjk5cOoJaxqg5J4A=="
},
"koftwentytwo.appkit": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )"
}
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit": "[0.2.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
@@ -163,4 +180,4 @@
}
}
}
}
}
+10 -1
View File
@@ -6,6 +6,7 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
@@ -27,6 +28,7 @@ public sealed class LogWindow : Window
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7, Margin = new Thickness(8, 0, 0, 0) };
private readonly TextBlock _limitNotice = new() { Text = "Older log content omitted", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 0, 0), Visibility = Visibility.Collapsed };
private readonly TextBox _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsReadOnly = true, BorderThickness = new Thickness(0), HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true, Padding = new Thickness(10, 4, 10, 4) };
private readonly ToggleButton _errorsOnly = new() { Content = "Errors only", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
@@ -42,6 +44,9 @@ public sealed class LogWindow : Window
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
AutomationProperties.SetAutomationId(_logText, "LogText");
AutomationProperties.SetAutomationId(_limitNotice, "LogTruncationNotice");
_logText.Loaded += (_, _) => _logText.ScrollToEnd();
Width = 1000;
Height = 680;
this.SetAppIcon(app);
@@ -66,6 +71,8 @@ public sealed class LogWindow : Window
buttons.Children.Add(openFolder);
DockPanel.SetDock(buttons, Dock.Left);
toolbar.Children.Add(buttons);
DockPanel.SetDock(_limitNotice, Dock.Right);
toolbar.Children.Add(_limitNotice);
toolbar.Children.Add(_pathText);
var root = new DockPanel();
@@ -134,8 +141,10 @@ public sealed class LogWindow : Window
_pathText.Text = _log.CurrentLogFilePath;
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
_limitNotice.Visibility = snapshot.IsTruncated ? Visibility.Visible : Visibility.Collapsed;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly);
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated);
_logText.UpdateLayout();
_logText.ScrollToEnd();
}
}
+9 -2
View File
@@ -8,12 +8,13 @@ using System.Windows;
using System.Windows.Media.Imaging;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
using Microsoft.Extensions.Logging;
namespace KofTwentyTwo.AppKit.Wpf;
/// <summary>App-level wiring every WPF app repeats: crash net and theme.</summary>
public static class WpfShell
public static partial class WpfShell
{
/// <summary>
/// Last-resort net: records exceptions that escape UI event handlers and keeps the
@@ -75,11 +76,17 @@ public static class WpfShell
{
try
{
log()?.Error($"{message}: {exception.Message}", exception);
LogUnhandledException((log() ?? NullActivityLog.Instance).AsLogger(), exception, message, exception.Message);
}
catch
{
// Logging must never turn a survivable error fatal.
}
}
/// <summary>Retains the crash-net source, error and full exception independently.</summary>
[LoggerMessage(10, LogLevel.Error, "{Source}: {Error}")]
private static partial void LogUnhandledException(ILogger logger, Exception exception, string source, string error);
}
+20 -3
View File
@@ -8,16 +8,33 @@
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"koftwentytwo.appkit": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )"
}
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit": "[0.2.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
@@ -26,4 +43,4 @@
}
}
}
}
}
@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace KofTwentyTwo.AppKit.Logging;
/// <summary>Adapts existing activity-log implementations to standard structured .NET logging.</summary>
public static class ActivityLogExtensions
{
/// <summary>
/// Creates a logger that records templates, fields, event IDs and scopes as JSON.
/// Keep and reuse the returned logger to share scopes. Existing activity-log
/// implementations need no new members; their writes retain the JSON record.
/// </summary>
public static ILogger AsLogger(this IActivityLog log)
{
ArgumentNullException.ThrowIfNull(log);
return log is NullActivityLog ? NullLogger.Instance : new ActivityLogLogger(log);
}
}
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace KofTwentyTwo.AppKit.Logging;
/// <summary>Stores standard logging state without reflection-based serialization or custom template parsing.</summary>
internal sealed class ActivityLogLogger : ILogger
{
private readonly IActivityLog _log;
private readonly LoggerExternalScopeProvider _scopes = new();
/// <summary>Wraps an existing sink; the public extension validates it.</summary>
public ActivityLogLogger(IActivityLog log) => _log = log;
/// <inheritdoc/>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => _scopes.Push(state);
/// <inheritdoc/>
public bool IsEnabled(LogLevel logLevel) => logLevel is >= LogLevel.Trace and < LogLevel.None;
/// <summary>Formats and writes one event; even malformed state or a failing custom sink cannot escape.</summary>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if(!IsEnabled(logLevel))
{
return;
}
try
{
string message = formatter(state, exception);
KeyValuePair<string, object?>[] fields = state is IEnumerable<KeyValuePair<string, object?>> values ? [.. values] : [];
string template = fields.LastOrDefault(pair => string.Equals(pair.Key, "{OriginalFormat}", StringComparison.Ordinal)).Value as string ?? message;
using var buffer = new MemoryStream();
using(var writer = new Utf8JsonWriter(buffer))
{
writer.WriteStartObject();
writer.WriteString("MessageTemplate", template);
writer.WriteString("Message", message);
writer.WriteString("Level", logLevel.ToString());
writer.WriteNumber("EventId", eventId.Id);
writer.WriteString("EventName", eventId.Name);
writer.WritePropertyName("Properties");
WriteProperties(writer, fields);
writer.WriteStartArray("Scopes");
_scopes.ForEachScope(static (scope, json) =>
{
if(scope is IEnumerable<KeyValuePair<string, object?>> properties)
{
WriteProperties(json, properties);
}
else
{
WriteValue(json, scope);
}
}, writer);
writer.WriteEndArray();
writer.WriteEndObject();
}
string entry = Encoding.UTF8.GetString(buffer.ToArray());
switch(logLevel)
{
case LogLevel.Warning:
_log.Warning(exception is null ? entry : entry + Environment.NewLine + exception);
break;
case LogLevel.Error:
case LogLevel.Critical:
_log.Error(entry, exception);
break;
default:
// The legacy sink has three severities; JSON retains the precise level.
if(exception is not null)
{
entry += Environment.NewLine + exception;
}
_log.Info(entry);
break;
}
}
catch
{
// Diagnostics must never stop the operation being diagnosed.
}
}
/// <summary>Writes named fields separately from the original message template.</summary>
private static void WriteProperties(Utf8JsonWriter writer, IEnumerable<KeyValuePair<string, object?>> properties)
{
writer.WriteStartObject();
foreach(KeyValuePair<string, object?> property in properties)
{
if(!string.Equals(property.Key, "{OriginalFormat}", StringComparison.Ordinal))
{
writer.WritePropertyName(property.Key);
WriteValue(writer, property.Value);
}
}
writer.WriteEndObject();
}
/// <summary>Preserves scalar JSON types; other values use an invariant string without inspecting object members.</summary>
private static void WriteValue(Utf8JsonWriter writer, object? value)
{
switch(value)
{
case null:
writer.WriteNullValue();
break;
case bool boolean:
writer.WriteBooleanValue(boolean);
break;
case byte or sbyte or short or ushort or int or uint or long:
writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture));
break;
case ulong unsigned:
writer.WriteNumberValue(unsigned);
break;
case decimal number:
writer.WriteNumberValue(number);
break;
case float or double:
double floating = Convert.ToDouble(value, CultureInfo.InvariantCulture);
if(double.IsFinite(floating))
{
writer.WriteNumberValue(floating);
}
else
{
writer.WriteStringValue(floating.ToString(CultureInfo.InvariantCulture));
}
break;
case JsonElement element:
element.WriteTo(writer);
break;
default:
writer.WriteStringValue(Convert.ToString(value, CultureInfo.InvariantCulture));
break;
}
}
}
@@ -8,7 +8,9 @@ namespace KofTwentyTwo.AppKit.Logging;
/// <summary>
/// Application activity log for human-readable operational events. Implementations
/// must be safe to call from any thread and must never throw from a logging call;
/// secrets (tokens, passwords) must never be written to it.
/// secrets (tokens, passwords) must never be written to it. Use
/// <see cref="ActivityLogExtensions.AsLogger"/> for structured templates and fields;
/// these legacy methods remain available for existing consumers and implementations.
/// </summary>
public interface IActivityLog
{
@@ -8,4 +8,8 @@ namespace KofTwentyTwo.AppKit.Logging;
/// <summary>The last lines of a log file and the file length they were read at.</summary>
/// <param name="Lines">The tail, oldest first.</param>
/// <param name="Length">File length in bytes when read; 0 when the file is absent.</param>
public sealed record LogSnapshot(IReadOnlyList<string> Lines, long Length);
public sealed record LogSnapshot(IReadOnlyList<string> Lines, long Length)
{
/// <summary>True when the input byte limit omitted a prefix of the file.</summary>
public bool IsTruncated { get; init; }
}
+66 -5
View File
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Text;
using System.Text.RegularExpressions;
@@ -19,13 +20,31 @@ public static partial class LogTail
/// <summary>Maximum trailing input per read; also bounds a single decoded line.</summary>
public const int DefaultMaxBytes = 1024 * 1024;
/// <summary>
/// The last <paramref name="maxLines"/> lines of <paramref name="path"/>, opened
/// shared so the logger can keep appending while it is read.
/// </summary>
public static LogSnapshot Read(string? path, int maxLines = DefaultMaxLines)
=> Read(path, maxLines, DefaultMaxBytes);
/// <summary>
/// Reads at most <paramref name="maxBytes"/> trailing bytes plus a four-byte encoding
/// header. UTF-8 and BOM-marked UTF-16/UTF-32 are supported. A skipped prefix may
/// leave a partial first line; <see cref="LogSnapshot.IsTruncated"/> identifies it.
/// The byte budget must be between four bytes and <see cref="DefaultMaxBytes"/>.
/// </summary>
public static LogSnapshot Read(string? path, int maxLines, int maxBytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLines);
ArgumentOutOfRangeException.ThrowIfLessThan(maxBytes, 4);
ArgumentOutOfRangeException.ThrowIfGreaterThan(maxBytes, DefaultMaxBytes);
try
{
if(string.IsNullOrWhiteSpace(path) || !File.Exists(path))
@@ -33,11 +52,20 @@ public static partial class LogTail
return new LogSnapshot([], 0);
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: 1);
long length = stream.Length;
using var reader = new StreamReader(stream);
Span<byte> header = stackalloc byte[4];
int headerLength = stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false);
(Encoding encoding, int preamble, int width) = DetectEncoding(header[..headerLength]);
long start = Math.Max(preamble, length - maxBytes);
// Round forward to a code-unit boundary without exceeding the input budget.
start += (width - ((start - preamble) % width)) % width;
stream.Position = start;
byte[] bytes = new byte[(int)(length - start)];
stream.ReadExactly(bytes);
using var reader = new StringReader(encoding.GetString(bytes));
var tail = new Queue<string>(maxLines);
var tail = new Queue<string>(Math.Min(maxLines, DefaultMaxLines));
while(reader.ReadLine() is string line)
{
if(tail.Count == maxLines)
@@ -46,7 +74,7 @@ public static partial class LogTail
}
tail.Enqueue(line);
}
return new LogSnapshot([.. tail], length);
return new LogSnapshot([.. tail], length) { IsTruncated = start > preamble };
}
catch(Exception ex)
{
@@ -56,6 +84,30 @@ public static partial class LogTail
/// <summary>Preserves the BOM detection supported by StreamReader, including UTF-32 before UTF-16.</summary>
private static (Encoding Encoding, int Preamble, int Width) DetectEncoding(ReadOnlySpan<byte> header)
{
if(header.StartsWith<byte>([0xFF, 0xFE, 0x00, 0x00]))
{
return (Encoding.UTF32, 4, 4);
}
if(header.StartsWith<byte>([0x00, 0x00, 0xFE, 0xFF]))
{
return (new UTF32Encoding(bigEndian: true, byteOrderMark: true), 4, 4);
}
if(header.StartsWith<byte>([0xFF, 0xFE]))
{
return (Encoding.Unicode, 2, 2);
}
if(header.StartsWith<byte>([0xFE, 0xFF]))
{
return (Encoding.BigEndianUnicode, 2, 2);
}
return header.StartsWith<byte>([0xEF, 0xBB, 0xBF]) ? (Encoding.UTF8, 3, 1) : (Encoding.UTF8, 0, 1);
}
/// <summary>
/// Keeps [ERROR] entries including their continuation lines: exception text spans
/// lines until the next timestamped entry starts.
@@ -83,11 +135,20 @@ public static partial class LogTail
/// <summary>The text a viewer shows for <paramref name="lines"/>, with friendly empty states.</summary>
public static string ToDisplayText(IReadOnlyList<string> lines, bool errorsOnly)
=> ToDisplayText(lines, errorsOnly, isTruncated: false);
/// <summary>Shows when the input byte limit hides older entries or cuts the first visible line.</summary>
public static string ToDisplayText(IReadOnlyList<string> lines, bool errorsOnly, bool isTruncated)
{
ArgumentNullException.ThrowIfNull(lines);
return lines.Count == 0
string text = lines.Count == 0
? (errorsOnly ? "No errors logged." : "No log entries yet.")
: string.Join(Environment.NewLine, lines);
return isTruncated
? "Showing a bounded log tail; older entries and the start of the first line may be omitted." + Environment.NewLine + text
: text;
}
+37 -1
View File
@@ -5,9 +5,45 @@ UI-framework-free foundation for KofTwentyTwo Windows apps.
- **`AppInfo`**: the app's identity (id, name, repository, credits, brand), declared once.
- **`AppPaths`**: per-user data under `%LOCALAPPDATA%\<id>`, relocatable with `<ID>_DATA_DIR`.
- **`BuildVersion`**: "1.2.0-beta.3 (9ac6c2b1f)" from the informational version, plus prerelease detection.
- **`FileActivityLog`** / **`LogTail`**: crash-safe daily log files with retention, and tail/filter helpers for log viewers.
- **`FileActivityLog`** / **`LogTail`**: crash-safe daily log files with retention, bounded tail/filter helpers, and an `ILogger` adapter for structured events.
- **`SettingsStore<T>`** / **`ShellSettings`**: never-throwing JSON settings with atomic saves, source-generated for trimming.
- **`CredentialManagerVault`**: secrets in the Windows Credential Manager, never on disk in plain text.
- **`IUserPrompter`**: the dialog seam shared flows use; the WinUI and WPF packages implement it.
Source, samples, and docs: https://github.com/KofTwentyTwo/AppKit
## Structured logging
Keep one `ILogger` from `activityLog.AsLogger()` and use static message templates.
Prefer source-generated events, as demonstrated in the sample apps:
```csharp
using KofTwentyTwo.AppKit.Logging;
using Microsoft.Extensions.Logging;
ILogger logger = activityLog.AsLogger();
AppEvents.LogSynced(logger, repository);
partial class AppEvents
{
[LoggerMessage(1, LogLevel.Information, "Synced {Repository}")]
public static partial void LogSynced(ILogger logger, string repository);
}
```
Each structured entry is a JSON object after the existing timestamp/level prefix.
`MessageTemplate` retains the original format; `Message` contains rendered text;
`Properties` retains named values. `EventId`, `EventName`, `Level`, and `Scopes` are
recorded separately. Exceptions retain their full continuation text. Daily rollover,
retention, thread safety, and nonthrowing writes use the existing file sink.
Nulls, booleans, integers, decimals, and finite floating-point values retain JSON
types. Nonfinite numbers and other objects use invariant strings; pass a
`JsonElement` for an explicit nested JSON value. Members of arbitrary objects are
never inspected. Scopes flow across async calls on the same adapter and disappear
when disposed. Do not put credentials or personal data in any field or scope.
The existing `Info`, `Warning`, and `Error` methods still write their original text
format, and existing `IActivityLog` implementations require no changes. The adapter
passes complete JSON records to those implementations. This capability requires
AppKit 0.2.0; 0.1.0 packages retain their published behavior.
+15 -1
View File
@@ -7,7 +7,21 @@
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
}
}
}
}
}
@@ -35,6 +35,16 @@ public class CreditsTests
/// <summary>A missing email does not introduce a leading separator before repository/website credits.</summary>
[Fact]
public void ContactLine_NoEmail_OmitsEmptyCredit()
{
var app = new AppInfo { Id = "sample", DisplayName = "Sample", RepositoryUrl = new Uri("https://github.com/owner/repo") };
Assert.Equal("github.com/owner", Credits.ContactLine(app));
}
/// <summary>The owner profile is host/owner for any repository URL, or just the host when the path is empty.</summary>
[Theory]
[InlineData(null, null)]
+16 -2
View File
@@ -132,10 +132,16 @@ internal sealed class FakeUpdateService : IUpdateService
public int ApplyCalls { get; private set; }
public int CheckCalls { get; private set; }
/// <summary>Returns the scripted check result.</summary>
public Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default) => Task.FromResult(NextCheck);
public Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default)
{
CheckCalls++;
return Task.FromResult(NextCheck);
}
@@ -152,9 +158,17 @@ internal sealed class FakeUpdateService : IUpdateService
/// <summary>A scriptable Velopack stand-in.</summary>
internal sealed class FakeBackend : IUpdateBackend
{
private string? _currentVersion = "1.0.0";
public bool IsInstalled { get; set; } = true;
public string? CurrentVersion { get; set; } = "1.0.0";
public string? CurrentVersion
{
get => VersionFailure is null ? _currentVersion : throw VersionFailure;
set => _currentVersion = value;
}
public Exception? VersionFailure { get; set; }
public string? Available { get; set; }
@@ -37,6 +37,7 @@ public class AppInfoTests
{
ArgumentException ex = Assert.Throws<ArgumentException>(() => new AppInfo { Id = id!, DisplayName = "X" });
Assert.Equal("value", ex.ParamName);
Assert.Contains("lowercase letters", ex.Message, StringComparison.Ordinal);
}
@@ -150,7 +151,9 @@ public class ArgbColorTests
[Fact]
public void Parse_Malformed_Throws()
{
Assert.Throws<FormatException>(() => ArgbColor.Parse("red"));
FormatException error = Assert.Throws<FormatException>(() => ArgbColor.Parse("red"));
Assert.Contains("red", error.Message, StringComparison.Ordinal);
Assert.Contains("#RRGGBB", error.Message, StringComparison.Ordinal);
}
}
@@ -235,6 +238,8 @@ public class BuildVersionTests
[InlineData("1.2.3+0123456789abcdef", "1.2.3 (012345678)")]
[InlineData("1.2.3+abc.more", "1.2.3 (abc)")]
[InlineData("1.2.3+", "1.2.3")]
[InlineData("+abc", " (abc)")]
[InlineData("1.2.3+.more", "1.2.3")]
public void Format_ShortensCommitMetadata(string? input, string expected)
{
Assert.Equal(expected, BuildVersion.Format(input));
@@ -247,6 +252,7 @@ public class BuildVersionTests
[InlineData(null, false)]
[InlineData("1.2.3", false)]
[InlineData("1.2.3+sha-with-dash", false)]
[InlineData("+sha-with-dash", false)]
[InlineData("1.2.3-dev", true)]
[InlineData("1.2.3-beta.1+abc", true)]
public void IsPrerelease_LooksOnlyAtTheVersionPart(string? input, bool expected)
@@ -293,8 +299,8 @@ public class BuildVersionTests
[Fact]
public void NullAssembly_Throws()
{
Assert.Throws<ArgumentNullException>(() => BuildVersion.Describe(null!));
Assert.Throws<ArgumentNullException>(() => BuildVersion.IsPrerelease((Assembly)null!));
Assert.Equal("assembly", Assert.Throws<ArgumentNullException>(() => BuildVersion.Describe(null!)).ParamName);
Assert.Equal("assembly", Assert.Throws<ArgumentNullException>(() => BuildVersion.IsPrerelease((Assembly)null!)).ParamName);
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using FsCheck.Xunit;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Generates hostile settings and log inputs; FsCheck shrinks and reports a replay seed on failure.</summary>
public sealed class InputProperties
{
/// <summary>Repair always produces a valid theme and bounded splash, without changing startup choices.</summary>
[Property(MaxTest = 500, EndSize = 256)]
public void Sanitize_ArbitraryValues_IsValidAndIdempotent(string? theme, int splash, bool showSplash, bool checkUpdates)
{
int[] durations = [int.MinValue, int.MaxValue, 0, splash];
foreach(int duration in durations)
{
var settings = new ShellSettings
{
Theme = theme!,
SplashMilliseconds = duration,
ShowSplashScreen = showSplash,
CheckForUpdatesOnStartup = checkUpdates,
};
settings.Sanitize();
AssertValid(settings);
string repairedTheme = settings.Theme;
int repairedDuration = settings.SplashMilliseconds;
settings.Sanitize();
Assert.Equal(repairedTheme, settings.Theme);
Assert.Equal(repairedDuration, settings.SplashMilliseconds);
Assert.Equal(showSplash, settings.ShowSplashScreen);
Assert.Equal(checkUpdates, settings.CheckForUpdatesOnStartup);
}
}
/// <summary>Arbitrary bytes, including broken UTF-8 and JSON, cannot stop loading preferences.</summary>
[Property(MaxTest = 500, EndSize = 256)]
public void Load_ArbitraryBytes_ReturnsUsableSettings(byte[] bytes)
{
using var temporary = new TempDirectory();
string path = temporary.File("settings.json");
File.WriteAllBytes(path, bytes);
var store = new SettingsStore<SampleSettings>(path, TestSettingsContext.Default.SampleSettings);
SampleSettings loaded = store.Load();
AssertValid(loaded);
Assert.InRange(loaded.Concurrency, 1, 64);
}
/// <summary>Valid JSON with arbitrary values is repaired, and repaired preferences survive a save/load cycle.</summary>
[Property(MaxTest = 500, EndSize = 256)]
public void Load_ArbitraryJsonValues_RepairsAndRoundTrips(string? theme, int splash, int concurrency, bool showSplash, bool checkUpdates)
{
using var temporary = new TempDirectory();
string path = temporary.File("settings.json");
var settings = new SampleSettings
{
Theme = theme!,
SplashMilliseconds = unchecked(splash * 1_000_003),
Concurrency = concurrency,
ShowSplashScreen = showSplash,
CheckForUpdatesOnStartup = checkUpdates,
};
File.WriteAllText(path, JsonSerializer.Serialize(settings, TestSettingsContext.Default.SampleSettings));
var store = new SettingsStore<SampleSettings>(path, TestSettingsContext.Default.SampleSettings);
SampleSettings loaded = store.Load();
AssertValid(loaded);
Assert.InRange(loaded.Concurrency, 1, 64);
Assert.Equal(showSplash, loaded.ShowSplashScreen);
Assert.Equal(checkUpdates, loaded.CheckForUpdatesOnStartup);
Assert.True(store.Save(loaded));
SampleSettings roundTripped = store.Load();
Assert.Equal(loaded.Theme, roundTripped.Theme);
Assert.Equal(loaded.SplashMilliseconds, roundTripped.SplashMilliseconds);
Assert.Equal(loaded.Concurrency, roundTripped.Concurrency);
Assert.Equal(showSplash, roundTripped.ShowSplashScreen);
Assert.Equal(checkUpdates, roundTripped.CheckForUpdatesOnStartup);
Assert.False(File.Exists(path + ".tmp"));
}
/// <summary>Malformed log bytes still yield the correct bounded tail, in original order.</summary>
[Property(MaxTest = 500, EndSize = 256)]
public void Read_ArbitraryLogBytes_ReturnsTheBoundedTail(byte[] bytes, byte limit)
{
using var temporary = new TempDirectory();
string path = temporary.File("input.log");
File.WriteAllBytes(path, bytes);
int maxLines = limit + 1;
LogSnapshot snapshot = LogTail.Read(path, maxLines);
Assert.InRange(snapshot.Lines.Count, 0, maxLines);
Assert.Equal(bytes.LongLength, snapshot.Length);
Assert.Equal(File.ReadAllLines(path).TakeLast(maxLines), snapshot.Lines, StringComparer.Ordinal);
}
/// <summary>Checks the public settings contract independently of the repair implementation.</summary>
private static void AssertValid(ShellSettings settings)
{
Assert.Contains(settings.Theme, new[] { "System", "Light", "Dark" }, StringComparer.Ordinal);
Assert.InRange(settings.SplashMilliseconds, ShellSettings.MinSplashMilliseconds, ShellSettings.MaxSplashMilliseconds);
}
}
@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="FsCheck.Xunit" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Text;
using KofTwentyTwo.AppKit.Logging;
@@ -129,6 +130,20 @@ public sealed class FileActivityLogTests : IDisposable
/// <summary>A log directory removed between writes is recreated rather than silently dropping diagnostics.</summary>
[Fact]
public void Info_RemovedDirectory_RecreatesAndWrites()
{
string directory = _temp.File("logs");
var log = new FileActivityLog(directory, "app", time: _clock);
log.Info("first");
Directory.Delete(directory, recursive: true);
log.Info("recovered");
Assert.Contains("recovered", File.ReadAllText(log.CurrentLogFilePath), StringComparison.Ordinal);
}
/// <summary>ForApp: uses the apps logs folder and id.</summary>
[Fact]
public void ForApp_UsesTheAppsLogsFolderAndId()
@@ -206,6 +221,116 @@ public sealed class LogTailTests : IDisposable
LogSnapshot snapshot = LogTail.Read(path, maxLines: 2);
Assert.Equal(["3", "4"], snapshot.Lines);
Assert.Equal(new FileInfo(path).Length, snapshot.Length);
Assert.False(snapshot.IsTruncated);
}
/// <summary>The viewer can read a log while its writer keeps a shared write handle open.</summary>
[Fact]
public void Read_WithOpenWriter_ReturnsCurrentTail()
{
string path = _temp.File("active.log");
using var writer = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
writer.Write(Encoding.UTF8.GetBytes("first\nlatest\n"));
writer.Flush();
Assert.Equal(["first", "latest"], LogTail.Read(path).Lines);
}
/// <summary>Seeking to the bounded tail preserves the final entries of a huge sparse file.</summary>
[Fact]
public void Read_HugeFile_ReturnsOrderedTailWithinDefaultBudget()
{
string path = _temp.File("huge.log");
const long Length = 128 * 1024 * 1024;
byte[] ending = Encoding.UTF8.GetBytes("\nolder\nlast one\nlast two\n");
using(var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
{
stream.SetLength(Length);
stream.Position = Length - ending.Length;
stream.Write(ending);
}
LogSnapshot snapshot = LogTail.Read(path, maxLines: 2);
Assert.Equal(["last one", "last two"], snapshot.Lines);
Assert.Equal(Length, snapshot.Length);
Assert.True(snapshot.IsTruncated);
}
/// <summary>The byte budget bounds a single line and avoids allocating a queue for an arbitrary line count.</summary>
[Fact]
public void Read_HugeSingleLine_BoundsTextAndQueueAllocation()
{
string path = _temp.File("one-line.log");
const int Length = LogTail.DefaultMaxBytes * 4;
using(var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
{
stream.SetLength(Length);
stream.Position = Length - 1;
stream.WriteByte((byte)'x');
}
LogSnapshot snapshot = LogTail.Read(path, maxLines: int.MaxValue);
string line = Assert.Single(snapshot.Lines);
Assert.Equal(LogTail.DefaultMaxBytes, line.Length);
Assert.EndsWith("x", line, StringComparison.Ordinal);
Assert.Equal(Length, snapshot.Length);
Assert.True(snapshot.IsTruncated);
Assert.Equal(["\0\0\0x"], LogTail.Read(path, maxLines: 1, maxBytes: 4).Lines);
}
/// <summary>BOM detection and truncated code-unit alignment preserve UTF-8, UTF-16 and UTF-32 in both byte orders.</summary>
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public void Read_SupportedEncodings_PreservesTailAndAlignment(int format)
{
Encoding encoding = format switch
{
0 => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
1 => new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
2 => Encoding.Unicode,
3 => Encoding.BigEndianUnicode,
4 => Encoding.UTF32,
_ => new UTF32Encoding(bigEndian: true, byteOrderMark: true),
};
string path = _temp.File("encoded.log");
const string Tail = "one π\ntwo λ\n";
File.WriteAllText(path, "skip prefix\n" + Tail, encoding);
LogSnapshot full = LogTail.Read(path);
Assert.Equal(File.ReadAllLines(path), full.Lines, StringComparer.Ordinal);
Assert.False(full.IsTruncated);
LogSnapshot bounded = LogTail.Read(path, maxLines: 2, maxBytes: encoding.GetByteCount(Tail) + 1);
Assert.Equal(["one π", "two λ"], bounded.Lines);
Assert.Equal(new FileInfo(path).Length, bounded.Length);
Assert.True(bounded.IsTruncated);
}
/// <summary>Empty files and BOM-only files contain no phantom entries.</summary>
[Fact]
public void Read_EmptyOrPreambleOnly_IsEmpty()
{
string path = _temp.File("empty.log");
File.WriteAllBytes(path, []);
Assert.Empty(LogTail.Read(path).Lines);
File.WriteAllBytes(path, Encoding.UTF32.GetPreamble());
LogSnapshot snapshot = LogTail.Read(path);
Assert.Empty(snapshot.Lines);
Assert.Equal(4, snapshot.Length);
Assert.False(snapshot.IsTruncated);
}
@@ -229,6 +354,8 @@ public sealed class LogTailTests : IDisposable
public void Read_RejectsNonPositiveLineCounts()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LogTail.Read("x", 0));
Assert.Throws<ArgumentOutOfRangeException>(() => LogTail.Read("x", 1, 3));
Assert.Throws<ArgumentOutOfRangeException>(() => LogTail.Read("x", 1, LogTail.DefaultMaxBytes + 1));
}
@@ -264,6 +391,19 @@ public sealed class LogTailTests : IDisposable
/// <summary>Both viewer modes disclose when the byte limit omitted input, including a partial first line.</summary>
[Theory]
[InlineData(false, "tail")]
[InlineData(true, "No errors logged.")]
public void ToDisplayText_TruncatedInput_ShowsNotice(bool errorsOnly, string expected)
{
string text = LogTail.ToDisplayText(errorsOnly ? [] : ["tail"], errorsOnly, isTruncated: true);
Assert.StartsWith("Showing a bounded log tail;", text, StringComparison.Ordinal);
Assert.EndsWith(expected, text, StringComparison.Ordinal);
}
/// <summary>NullArguments: throw.</summary>
[Fact]
public void NullArguments_Throw()
@@ -36,6 +36,7 @@ public class InMemorySecretVaultTests
Assert.Throws<ArgumentException>(() => vault.Store("", "v"));
Assert.Throws<ArgumentNullException>(() => vault.Store("k", null!));
Assert.Throws<ArgumentNullException>(() => vault.TryRetrieve(null!));
Assert.Throws<ArgumentException>(() => vault.TryRetrieve(""));
Assert.Throws<ArgumentException>(() => vault.Delete(""));
}
}
@@ -101,7 +102,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
// The Credential Manager caps blobs at 2560 bytes; silently losing a secret is worse than throwing.
Win32Exception ex = Assert.Throws<Win32Exception>(() => _vault.Store("token", new string('x', 5000)));
Assert.Contains("CredWriteW", ex.Message, StringComparison.Ordinal);
Assert.Throws<ArgumentNullException>(() => _vault.Store("token", null!));
Assert.Equal("secret", Assert.Throws<ArgumentNullException>(() => _vault.Store("token", null!)).ParamName);
}
@@ -110,8 +111,12 @@ public sealed class CredentialManagerVaultTests : IDisposable
[Fact]
public void InvalidTargets_SurfaceNativeErrors()
{
Assert.Throws<Win32Exception>(() => CredentialManagerVault.TryRetrieveTarget("", "k"));
Assert.Throws<Win32Exception>(() => CredentialManagerVault.DeleteTarget("", "k"));
Win32Exception read = Assert.Throws<Win32Exception>(() => CredentialManagerVault.TryRetrieveTarget("", "k"));
Assert.Contains("CredReadW", read.Message, StringComparison.Ordinal);
Assert.Contains("'k'", read.Message, StringComparison.Ordinal);
Win32Exception delete = Assert.Throws<Win32Exception>(() => CredentialManagerVault.DeleteTarget("", "k"));
Assert.Contains("CredDeleteW", delete.Message, StringComparison.Ordinal);
Assert.Contains("'k'", delete.Message, StringComparison.Ordinal);
}
@@ -120,7 +125,8 @@ public sealed class CredentialManagerVaultTests : IDisposable
[Fact]
public void Constructor_ValidatesPlatformAndPrefix()
{
Assert.Throws<PlatformNotSupportedException>(() => new CredentialManagerVault("x", isWindows: false));
PlatformNotSupportedException error = Assert.Throws<PlatformNotSupportedException>(() => new CredentialManagerVault("x", isWindows: false));
Assert.Contains("Windows Credential Manager", error.Message, StringComparison.Ordinal);
Assert.Throws<ArgumentException>(() => new CredentialManagerVault(" "));
}
}
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.Json.Serialization;
using KofTwentyTwo.AppKit.Settings;
@@ -93,6 +94,23 @@ public sealed class SettingsStoreTests : IDisposable
/// <summary>Invalid settings are repaired in memory and on disk before a later load can mask bad writes.</summary>
[Fact]
public void Save_InvalidSettings_SanitizesBeforeWriting()
{
SettingsStore<SampleSettings> store = Store();
var settings = new SampleSettings { Concurrency = 500, Theme = "Purple", SplashMilliseconds = int.MaxValue };
Assert.True(store.Save(settings));
Assert.Equal(64, settings.Concurrency);
Assert.Equal("System", settings.Theme);
Assert.Equal(ShellSettings.MaxSplashMilliseconds, settings.SplashMilliseconds);
using var document = JsonDocument.Parse(File.ReadAllText(store.Path));
Assert.Equal(64, document.RootElement.GetProperty("Concurrency").GetInt32());
Assert.Equal("System", document.RootElement.GetProperty("Theme").GetString());
}
/// <summary>Load: corrupt or null file returns defaults.</summary>
[Theory]
[InlineData("{ not json")]
@@ -0,0 +1,286 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using KofTwentyTwo.AppKit.Logging;
using Microsoft.Extensions.Logging;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Verifies standard templates, typed fields, scopes, failure containment and file sink behavior.</summary>
public sealed class StructuredLoggingTests : IDisposable
{
private readonly TempDirectory _temp = new();
/// <summary>Deletes the temporary log folder.</summary>
public void Dispose() => _temp.Dispose();
/// <summary>Standard .NET templating preserves the original format and typed values independently of rendering.</summary>
[Fact]
public void Log_StandardTemplate_PreservesFieldsAndEventIdentity()
{
var sink = new RecordingLog();
ILogger logger = sink.AsLogger();
Action<ILogger, int, string, Exception?> logEvent = LoggerMessage.Define<int, string>(
LogLevel.Information, new EventId(42, "Processed"), "Processed {Count:N0} for {Target}.");
logEvent(logger, 1234, "sample", null);
JsonElement record = ReadRecord(sink.Entries.Single());
Assert.Equal("Processed {Count:N0} for {Target}.", record.GetProperty("MessageTemplate").GetString());
Assert.Equal("Processed 1,234 for sample.", record.GetProperty("Message").GetString());
Assert.Equal("Information", record.GetProperty("Level").GetString());
Assert.Equal(42, record.GetProperty("EventId").GetInt32());
Assert.Equal("Processed", record.GetProperty("EventName").GetString());
Assert.Equal(1234, record.GetProperty("Properties").GetProperty("Count").GetInt32());
Assert.Equal("sample", record.GetProperty("Properties").GetProperty("Target").GetString());
Assert.False(record.GetProperty("Properties").TryGetProperty("{OriginalFormat}", out _));
}
/// <summary>JSON scalars keep their types; complex values have an explicit invariant string fallback.</summary>
[Fact]
public void Log_ScalarFields_PreservesTypesAndEscapesText()
{
var sink = new RecordingLog();
using var nested = JsonDocument.Parse("{\"nested\":true}");
KeyValuePair<string, object?>[] fields =
[
new("{OriginalFormat}", "Scalar event"), new("Null", null), new("Boolean", true),
new("Byte", (byte)255), new("SByte", (sbyte)-128), new("Short", (short)-32768),
new("UShort", ushort.MaxValue), new("Int", int.MinValue), new("UInt", uint.MaxValue),
new("Long", long.MinValue), new("ULong", ulong.MaxValue), new("Decimal", 123.45m),
new("Float", 1.5f), new("Double", 2.5d), new("NaN", double.NaN),
new("Infinity", double.PositiveInfinity), new("Text", "quoted \"text\"\nnext line"),
new("Guid", Guid.Empty), new("Nested", nested.RootElement),
];
sink.AsLogger().Log(LogLevel.Debug, default, fields, null, static (_, _) => "Scalar event");
JsonElement record = ReadRecord(sink.Entries.Single());
JsonElement values = record.GetProperty("Properties");
Assert.Equal(JsonValueKind.Null, values.GetProperty("Null").ValueKind);
Assert.True(values.GetProperty("Boolean").GetBoolean());
foreach(string key in new[] { "Byte", "SByte", "Short", "UShort", "Int", "UInt", "Long", "ULong", "Decimal", "Float", "Double" })
{
Assert.Equal(JsonValueKind.Number, values.GetProperty(key).ValueKind);
}
Assert.Equal(long.MinValue, values.GetProperty("Long").GetInt64());
Assert.Equal(ulong.MaxValue, values.GetProperty("ULong").GetUInt64());
Assert.Equal(123.45m, values.GetProperty("Decimal").GetDecimal());
Assert.Equal(1.5d, values.GetProperty("Float").GetDouble());
Assert.Equal(2.5d, values.GetProperty("Double").GetDouble());
Assert.Equal("NaN", values.GetProperty("NaN").GetString());
Assert.Equal("Infinity", values.GetProperty("Infinity").GetString());
Assert.Equal("quoted \"text\"\nnext line", values.GetProperty("Text").GetString());
Assert.Equal(Guid.Empty.ToString(), values.GetProperty("Guid").GetString());
Assert.True(values.GetProperty("Nested").GetProperty("nested").GetBoolean());
Assert.DoesNotContain('\n', sink.Entries.Single());
Assert.Equal(JsonValueKind.Null, record.GetProperty("EventName").ValueKind);
}
/// <summary>All enabled severities retain the original exception text, even those without a legacy exception overload.</summary>
[Theory]
[InlineData(LogLevel.Trace, "INFO")]
[InlineData(LogLevel.Debug, "INFO")]
[InlineData(LogLevel.Information, "INFO")]
[InlineData(LogLevel.Warning, "WARN")]
[InlineData(LogLevel.Error, "ERROR")]
[InlineData(LogLevel.Critical, "ERROR")]
public void Log_EachSeverity_RetainsFullException(LogLevel level, string prefix)
{
var sink = new FileActivityLog(_temp.Path, "app");
var exception = new InvalidOperationException("outer detail", new IOException("inner detail"));
LoggerMessage.Define<int>(level, new EventId(7), "Failure {Code}")(sink.AsLogger(), 17, exception);
string text = File.ReadAllText(sink.CurrentLogFilePath);
Assert.Contains("[" + prefix + "] {", text, StringComparison.Ordinal);
Assert.Contains(exception.ToString(), text, StringComparison.Ordinal);
JsonElement record = ReadRecord(File.ReadLines(sink.CurrentLogFilePath).First());
Assert.Equal(level.ToString(), record.GetProperty("Level").GetString());
Assert.Equal(17, record.GetProperty("Properties").GetProperty("Code").GetInt32());
if(level is LogLevel.Error or LogLevel.Critical)
{
Assert.Contains(exception.ToString(), LogTail.ToDisplayText(LogTail.ErrorsOnly(LogTail.Read(sink.CurrentLogFilePath).Lines), true), StringComparison.Ordinal);
}
}
/// <summary>Unstructured states remain readable; nested structured and string scopes are disposed independently.</summary>
[Fact]
public void Log_NestedScopes_RetainsFieldsAndDisposalOrder()
{
var sink = new RecordingLog();
ILogger logger = sink.AsLogger();
using(logger.BeginScope("outer"))
{
using(logger.BeginScope(new Dictionary<string, object?>(StringComparer.Ordinal) { ["OperationId"] = 7 }))
{
logger.Log(LogLevel.Information, default, "plain", null, static (state, _) => state);
}
logger.Log(LogLevel.Information, default, "after", null, static (state, _) => state);
}
logger.Log(LogLevel.Information, default, "outside", null, static (state, _) => state);
JsonElement first = ReadRecord(sink.Entries[0]);
Assert.Equal("plain", first.GetProperty("MessageTemplate").GetString());
Assert.Empty(first.GetProperty("Properties").EnumerateObject());
Assert.Equal("outer", first.GetProperty("Scopes")[0].GetString());
Assert.Equal(7, first.GetProperty("Scopes")[1].GetProperty("OperationId").GetInt32());
Assert.Single(ReadRecord(sink.Entries[1]).GetProperty("Scopes").EnumerateArray());
Assert.Empty(ReadRecord(sink.Entries[2]).GetProperty("Scopes").EnumerateArray());
}
/// <summary>Concurrent asynchronous scopes cannot mix operation fields or interleave daily file records.</summary>
[Fact]
public async Task Log_ConcurrentAsyncScopes_KeepsWholeRecordsAndIsolatesFields()
{
var sink = new FileActivityLog(_temp.Path, "parallel");
ILogger logger = sink.AsLogger();
Action<ILogger, int, Exception?> logEvent = LoggerMessage.Define<int>(LogLevel.Information, new EventId(9), "Operation {OperationId}");
await Task.WhenAll(Enumerable.Range(0, 100).Select(async id =>
{
using(logger.BeginScope(new Dictionary<string, object?>(StringComparer.Ordinal) { ["OperationId"] = id }))
{
await Task.Yield();
logEvent(logger, id, null);
}
}));
string[] lines = File.ReadAllLines(sink.CurrentLogFilePath);
Assert.Equal(100, lines.Length);
var seen = new HashSet<int>();
foreach(string line in lines)
{
JsonElement record = ReadRecord(line);
int id = record.GetProperty("Properties").GetProperty("OperationId").GetInt32();
Assert.Equal(id, record.GetProperty("Scopes")[0].GetProperty("OperationId").GetInt32());
Assert.True(seen.Add(id));
}
}
/// <summary>Structured writes use the same daily rollover and retention policy as legacy entries.</summary>
[Fact]
public void Log_DailyFile_RetainsRolloverAndPruning()
{
var clock = new ManualTimeProvider(new DateTimeOffset(2026, 3, 15, 9, 30, 0, TimeSpan.Zero));
File.WriteAllText(_temp.File("app-2026-03-12.log"), "stale");
var sink = new FileActivityLog(_temp.Path, "app", retentionDays: 3, time: clock);
ILogger logger = sink.AsLogger();
logger.Log(LogLevel.Information, default, "day one", null, static (state, _) => state);
Assert.False(File.Exists(_temp.File("app-2026-03-12.log")));
clock.Now = clock.Now.AddDays(1);
logger.Log(LogLevel.Information, default, "day two", null, static (state, _) => state);
Assert.Equal("day one", ReadRecord(File.ReadAllText(_temp.File("app-2026-03-15.log"))).GetProperty("Message").GetString());
Assert.Equal("day two", ReadRecord(File.ReadAllText(_temp.File("app-2026-03-16.log"))).GetProperty("Message").GetString());
}
/// <summary>Disabled levels bypass formatting and never write an event.</summary>
[Theory]
[InlineData(LogLevel.None)]
[InlineData((LogLevel)(-1))]
[InlineData((LogLevel)7)]
public void Log_DisabledLevel_DoesNotEvaluateState(LogLevel level)
{
var sink = new RecordingLog();
ILogger logger = sink.AsLogger();
Assert.False(logger.IsEnabled(level));
bool evaluated = false;
logger.Log(level, default, "state", null, (_, _) =>
{
evaluated = true;
return "must not format";
});
Assert.False(evaluated);
Assert.Empty(sink.Entries);
}
/// <summary>A warning without an exception remains one JSON event without an extra blank line.</summary>
[Fact]
public void Log_WarningWithoutException_RemainsSingleLine()
{
var sink = new RecordingLog();
sink.AsLogger().Log(LogLevel.Warning, default, "warning", null, static (state, _) => state);
string entry = Assert.Single(sink.Entries);
Assert.DoesNotContain('\n', entry);
Assert.DoesNotContain('\r', entry);
Assert.Equal("warning", ReadRecord(entry).GetProperty("Message").GetString());
}
/// <summary>Formatter errors and custom sink failures cannot escape any supported logging severity.</summary>
[Fact]
public void Log_FailingFormatterOrSink_NeverThrows()
{
var sink = new RecordingLog();
sink.AsLogger().Log(LogLevel.Information, default, "state", null, static (_, _) => throw new InvalidOperationException("formatter failed"));
Assert.Empty(sink.Entries);
ILogger logger = new ThrowingActivityLog().AsLogger();
foreach(LogLevel level in new[] { LogLevel.Information, LogLevel.Warning, LogLevel.Error })
{
logger.Log(level, default, "state", null, static (state, _) => state);
}
}
/// <summary>Existing null sinks stay disabled; only creating an adapter rejects a missing sink.</summary>
[Fact]
public void AsLogger_NullSink_IsDisabledAndNullArgumentIsRejected()
{
Assert.False(NullActivityLog.Instance.AsLogger().IsEnabled(LogLevel.Critical));
Assert.Throws<ArgumentNullException>(() => ActivityLogExtensions.AsLogger(null!));
}
/// <summary>Reads the JSON after either the file prefix or a recording sink's severity.</summary>
private static JsonElement ReadRecord(string entry)
{
using var document = JsonDocument.Parse(entry[entry.IndexOf('{', StringComparison.Ordinal)..]);
return document.RootElement.Clone();
}
}
/// <summary>A legacy sink that deliberately violates its contract to exercise the adapter's containment.</summary>
internal sealed class ThrowingActivityLog : IActivityLog
{
public string LogDirectory => "";
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message) => throw new IOException("sink failed");
/// <inheritdoc/>
public void Warning(string message) => throw new IOException("sink failed");
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => throw new IOException("sink failed");
}
@@ -0,0 +1,185 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Collections.Concurrent;
using System.Diagnostics;
using KofTwentyTwo.AppKit.Interaction;
using KofTwentyTwo.AppKit.Updates;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Exercises genuinely asynchronous update completions while a single UI thread pumps its context.</summary>
public sealed class UpdateContextTests
{
/// <summary>Check, confirmation and download completions return to the UI context before the next prompt.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Check_AsynchronousUpdate_InvokesPromptsOnOriginalContext(bool quietly)
{
using var context = new PumpedContext();
var prompter = new ContextPrompter(context);
var coordinator = new UpdateCoordinator(new ContextUpdates(context), prompter, "Sample");
await context.Run(() => quietly ? coordinator.CheckQuietlyAsync() : coordinator.CheckInteractivelyAsync());
Assert.Equal(1, prompter.Confirmations);
Assert.Equal(["disk full"], prompter.Messages);
}
/// <summary>The UI-independent backend applies after download without requiring the caller's UI context.</summary>
[Fact]
public async Task Download_AsynchronousCompletion_AppliesWithoutUiContext()
{
using var context = new PumpedContext();
var backend = new ContextBackend(context);
var service = new VelopackUpdateService(() => backend);
await context.Run(async () =>
{
Assert.True((await service.CheckAsync()).IsUpdateAvailable);
Assert.Null(await service.DownloadAndApplyAsync());
});
Assert.True(backend.Applied);
}
/// <summary>A bounded single-thread pump; completions are queued so each tested await starts with an incomplete task.</summary>
private sealed class PumpedContext : SynchronizationContext, IDisposable
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _callbacks = new();
/// <summary>Queues a callback for the thread running the pump.</summary>
public override void Post(SendOrPostCallback d, object? state) => _callbacks.Add((d, state));
/// <summary>Runs an asynchronous operation while pumping callbacks, then restores the test framework's context.</summary>
public Task Run(Func<Task> start)
{
SynchronizationContext? previous = Current;
SetSynchronizationContext(this);
try
{
Task operation = start();
long began = Stopwatch.GetTimestamp();
while(!operation.IsCompleted)
{
Assert.True(Stopwatch.GetElapsedTime(began) < TimeSpan.FromSeconds(30), "Update flow did not complete while pumping its UI context.");
if(_callbacks.TryTake(out (SendOrPostCallback Callback, object? State) callback, millisecondsTimeout: 50))
{
callback.Callback(callback.State);
}
}
return operation;
}
finally
{
SetSynchronizationContext(previous);
}
}
/// <summary>Completes after the next pump iteration, with continuations scheduled rather than inlined.</summary>
public Task<T> CompleteLater<T>(T value)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
Post(_ => completion.SetResult(value), null);
return completion.Task;
}
/// <summary>Releases the callback queue after the operation has finished.</summary>
public void Dispose() => _callbacks.Dispose();
}
/// <summary>An update service whose operations complete asynchronously through the test UI pump.</summary>
private sealed class ContextUpdates(PumpedContext context) : IUpdateService
{
public bool IsSupported => true;
public string? CurrentVersion => "1.0.0";
/// <summary>Offers a version only after the caller has yielded.</summary>
public Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default)
=> context.CompleteLater(UpdateCheckResult.Available("2.0.0"));
/// <summary>Returns a recoverable installation failure after the caller has yielded.</summary>
public Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default)
=> context.CompleteLater<string?>("disk full");
}
/// <summary>A backend that completes through the pump but verifies installation proceeds without a UI context.</summary>
private sealed class ContextBackend(PumpedContext context) : IUpdateBackend
{
public bool IsInstalled => true;
public string? CurrentVersion => "1.0.0";
public bool Applied { get; private set; }
/// <summary>Checks complete after the caller yields.</summary>
public Task<string?> CheckAsync() => context.CompleteLater<string?>("2.0.0");
/// <summary>Downloads complete after the caller yields.</summary>
public Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken) => context.CompleteLater(true);
/// <summary>Rejects a UI-context dependency before recording the application.</summary>
public void ApplyAndRestart()
{
Assert.Null(SynchronizationContext.Current);
Applied = true;
}
}
/// <summary>A dialog adapter that rejects calls from a worker thread or another synchronization context.</summary>
private sealed class ContextPrompter(PumpedContext context) : IUserPrompter
{
public int Confirmations { get; private set; }
public List<string> Messages { get; } = [];
/// <summary>Checks context affinity and records the installation failure.</summary>
public Task ShowMessageAsync(string title, string message)
{
Assert.Same(context, SynchronizationContext.Current);
Messages.Add(message);
return context.CompleteLater(true);
}
/// <summary>Checks context affinity and answers asynchronously, exercising the next continuation too.</summary>
public Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
Assert.Same(context, SynchronizationContext.Current);
Confirmations++;
return context.CompleteLater(true);
}
}
}
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using KofTwentyTwo.AppKit.Updates;
@@ -27,6 +28,35 @@ public class UpdateCheckResultTests
/// <summary>Tests for the update service&apos;s guarding and state, over a fake Velopack backend.</summary>
public class VelopackUpdateServiceTests
{
/// <summary>Repeated property reads and checks share the same lazy backend rather than recreating an update manager.</summary>
[Fact]
public async Task Backend_RepeatedAccess_CreatesOnce()
{
int creations = 0;
var service = new VelopackUpdateService(() =>
{
creations++;
return new FakeBackend();
});
Assert.True(service.IsSupported);
Assert.Equal("1.0.0", service.CurrentVersion);
await service.CheckAsync();
await service.CheckAsync();
Assert.Equal(1, creations);
}
/// <summary>Reading an unavailable installed version fails closed without throwing into an app's UI.</summary>
[Fact]
public void CurrentVersion_BackendThrows_ReturnsNull()
{
var service = new VelopackUpdateService(() => new FakeBackend { VersionFailure = new IOException("missing manifest") });
Assert.Null(service.CurrentVersion);
}
/// <summary>NotInstalled: is unsupported.</summary>
[Fact]
public async Task NotInstalled_IsUnsupported()
@@ -179,7 +209,7 @@ public class UpdateCoordinatorTests
var prompter = new RecordingPrompter();
await Coordinator(prompter).CheckInteractivelyAsync();
Assert.Equal("Could not check for updates.\noffline", prompter.Messages.Single().Message);
Assert.Equal("WARN Update check failed: offline", _log.Entries.Single());
AssertLogEntry(_log.Entries.Single(), "WARN", "Update check failed: offline", "Update check failed: {Error}", ("Error", "offline"));
}
@@ -194,6 +224,8 @@ public class UpdateCoordinatorTests
var prompter = new RecordingPrompter();
await Coordinator(prompter).CheckInteractivelyAsync();
Assert.Equal(expected, prompter.Messages.Single().Message);
Assert.Empty(prompter.Confirmations);
Assert.Equal(0, _updates.ApplyCalls);
}
@@ -224,7 +256,7 @@ public class UpdateCoordinatorTests
await Coordinator(prompter).CheckInteractivelyAsync();
Assert.Equal(1, _updates.ApplyCalls);
Assert.Empty(prompter.Messages);
Assert.Equal("INFO Installing update v2.0.0.", _log.Entries.Single());
AssertLogEntry(_log.Entries.Single(), "INFO", "Installing update v2.0.0.", "Installing update v{Version}.", ("Version", "2.0.0"));
}
@@ -238,7 +270,7 @@ public class UpdateCoordinatorTests
var prompter = new RecordingPrompter(confirm: true);
await Coordinator(prompter).CheckInteractivelyAsync();
Assert.Equal((UpdateText.FailedTitle, "disk full"), prompter.Messages.Single());
Assert.Equal("ERROR Update to v2.0.0 failed: disk full", _log.Entries.Last());
AssertLogEntry(_log.Entries.Last(), "ERROR", "Update to v2.0.0 failed: disk full", "Update to v{Version} failed: {Error}", ("Version", "2.0.0"), ("Error", "disk full"));
}
@@ -250,6 +282,7 @@ public class UpdateCoordinatorTests
var prompter = new RecordingPrompter();
_updates.IsSupported = false;
await Coordinator(prompter).CheckQuietlyAsync();
Assert.Equal(0, _updates.CheckCalls);
_updates.IsSupported = true;
await Coordinator(prompter).CheckQuietlyAsync();
@@ -259,7 +292,7 @@ public class UpdateCoordinatorTests
Assert.Empty(prompter.Messages);
Assert.Empty(prompter.Confirmations);
Assert.Equal("WARN Startup update check failed: offline", _log.Entries.Single());
AssertLogEntry(_log.Entries.Single(), "WARN", "Startup update check failed: offline", "Startup update check failed: {Error}", ("Error", "offline"));
}
@@ -273,7 +306,31 @@ public class UpdateCoordinatorTests
await Coordinator(prompter).CheckQuietlyAsync();
Assert.Single(prompter.Confirmations);
Assert.Equal(1, _updates.ApplyCalls);
Assert.Equal("INFO Update available: v2.0.0.", _log.Entries[0]);
AssertLogEntry(_log.Entries[0], "INFO", "Update available: v2.0.0.", "Update available: v{Version}.", ("Version", "2.0.0"));
}
/// <summary>An error wins over a conflicting available version and never prompts an installation.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Check_FailedResultWithVersion_DoesNotOfferInstall(bool quietly)
{
_updates.NextCheck = new UpdateCheckResult("2.0.0", "offline");
var prompter = new RecordingPrompter(confirm: true);
if(quietly)
{
await Coordinator(prompter).CheckQuietlyAsync();
Assert.Empty(prompter.Messages);
}
else
{
await Coordinator(prompter).CheckInteractivelyAsync();
Assert.Contains("offline", prompter.Messages.Single().Message, StringComparison.Ordinal);
}
Assert.Empty(prompter.Confirmations);
Assert.Equal(0, _updates.ApplyCalls);
}
@@ -297,4 +354,20 @@ public class UpdateCoordinatorTests
Assert.Throws<ArgumentNullException>(() => new UpdateCoordinator(_updates, null!, "x"));
Assert.Throws<ArgumentException>(() => new UpdateCoordinator(_updates, prompter, " "));
}
/// <summary>Checks rendered compatibility and separate named fields at the existing sink boundary.</summary>
private static void AssertLogEntry(string entry, string level, string message, string template, params (string Name, string Value)[] properties)
{
Assert.StartsWith(level + " ", entry, StringComparison.Ordinal);
using var document = JsonDocument.Parse(entry[(level.Length + 1)..]);
JsonElement record = document.RootElement;
Assert.Equal(message, record.GetProperty("Message").GetString());
Assert.Equal(template, record.GetProperty("MessageTemplate").GetString());
foreach((string name, string value) in properties)
{
Assert.Equal(value, record.GetProperty("Properties").GetProperty(name).GetString());
}
}
}
@@ -8,6 +8,17 @@
"resolved": "10.1.0",
"contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ=="
},
"FsCheck.Xunit": {
"type": "Direct",
"requested": "[3.4.0, )",
"resolved": "3.4.0",
"contentHash": "AkrVSuLzu1KqTFPAJHMOzZf68MgnvGNFIblytfiZpRSAY+SFS7n0GdrhL005gLnUzeWdWs85rO1nx1uzmRSmSQ==",
"dependencies": {
"FSharp.Core": "5.0.2",
"FsCheck": "[3.4.0]",
"xunit.extensibility.execution": "[2.4.1, 3.0.0)"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
@@ -41,11 +52,29 @@
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"FsCheck": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "wkWR1DORrKd46W6MEhmS4muo/0kgrtUV/AGW1ohMWemy7WukcRH8T3JjkDpn/daOHk0yVqf0CBvScU17Ab//PA==",
"dependencies": {
"FSharp.Core": "5.0.2"
}
},
"FSharp.Core": {
"type": "Transitive",
"resolved": "5.0.2",
"contentHash": "DpcajqENgb3VXaHw1FKfVS+TWwEzck1PCajqLwBAVrtd1lfxd7T8JISVZBdV1mX9+2g48+tIgpNaVJWObdMrBw=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.10.1",
@@ -100,15 +129,27 @@
}
},
"koftwentytwo.appkit": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )"
}
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit": "[0.2.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
@@ -117,4 +158,4 @@
}
}
}
}
}
+35 -24
View File
@@ -36,6 +36,7 @@ public sealed class AppSession : IDisposable
private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan s_launchTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan s_pollInterval = TimeSpan.FromMilliseconds(250);
private readonly DpiAwarenessScope _dpi;
@@ -52,9 +53,10 @@ public sealed class AppSession : IDisposable
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
startInfo.Environment["APPKIT_SAMPLE_DATA_DIR"] = DataDirectory;
Automation = new UIA3Automation();
_dpi = new DpiAwarenessScope();
try
{
Automation = new UIA3Automation();
App = Application.Launch(startInfo);
MainWindow = WaitForMainWindow();
}
@@ -148,12 +150,14 @@ public sealed class AppSession : IDisposable
/// <summary>
/// Opens the top-level menu named <paramref name="menuName"/> and invokes the item
/// <paramref name="itemAutomationId"/>. A freshly started app can swallow the first
/// <paramref name="itemAutomationId"/>. Foregrounds this app so mouse fallback cannot
/// land in another sample's window. A freshly started app can swallow the first
/// expand (its flyout opens before the menu is ready), so the menu is re-opened
/// until the item appears.
/// </summary>
public void InvokeMenuItem(string menuName, string itemAutomationId)
{
MainWindow.SetForeground();
AutomationElement menu = WaitFor(
() => MainWindow.FindFirstDescendant(cf => cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuName))),
$"menu '{menuName}'");
@@ -293,32 +297,39 @@ public sealed class AppSession : IDisposable
{
try
{
App?.Close();
}
catch
{
// Already gone; the kill below double-checks.
}
try
{
if(App is { HasExited: false })
try
{
App.Kill();
App?.Close();
}
catch
{
// Already gone; the kill below double-checks.
}
try
{
if(App is { HasExited: false })
{
App.Kill();
}
}
catch
{
// Best effort.
}
App?.Dispose();
Automation?.Dispose();
try
{
Directory.Delete(DataDirectory, recursive: true);
}
catch
{
// A log handle can linger briefly after exit; the temp cleaner gets it.
}
}
catch
finally
{
// Best effort.
}
App?.Dispose();
Automation.Dispose();
try
{
Directory.Delete(DataDirectory, recursive: true);
}
catch
{
// A log handle can linger briefly after exit; the temp cleaner gets it.
_dpi.Dispose();
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace KofTwentyTwo.AppKit.UiTests;
/// <summary>Uses physical screen coordinates for UI input and capture, restoring the synchronous test thread afterwards.</summary>
internal sealed class DpiAwarenessScope : IDisposable
{
private readonly nint _previous;
/// <summary>Disables DPI virtualization on this thread with per-monitor awareness v2 (Windows 10 1703+).</summary>
public DpiAwarenessScope()
{
_previous = SetThreadDpiAwarenessContext(-4);
if(_previous == 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError());
}
}
/// <summary>Restores the context on the same thread that ran the synchronous UI test.</summary>
public void Dispose() => SetThreadDpiAwarenessContext(_previous);
/// <summary>Windows' thread-scoped DPI override, supported by the repository's minimum OS.</summary>
[DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
private static extern nint SetThreadDpiAwarenessContext(nint dpiContext);
}
@@ -3,7 +3,9 @@
* SPDX-License-Identifier: MIT
*/
using System.Drawing;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Capturing;
namespace KofTwentyTwo.AppKit.UiTests;
@@ -76,6 +78,52 @@ public class SampleAppTests
/// <summary>Huge logs retain the newest entries and a visible notice even while Follow scrolls to the end.</summary>
[Theory]
[InlineData(SampleKind.WinUI)]
[InlineData(SampleKind.Wpf)]
public void ActivityLog_HugeFile_ShowsLatestEntryAndPersistentLimitNotice(SampleKind kind)
{
using var session = new AppSession(kind);
string path = Directory.GetFiles(Path.Combine(session.DataDirectory, "logs"), "*.log").Single();
using(var writer = new StreamWriter(path, append: true))
{
string padding = new('x', 80);
for(int index = 0; index < 30_000; index++)
{
writer.WriteLine("2026-10-10 12:00:00.000 [INFO] Older entry " + padding);
}
writer.WriteLine("2026-10-10 12:00:01.000 [INFO] Latest entry marker");
}
session.InvokeMenuItem("Help", "ActivityLogMenuItem");
AutomationElement text = session.WaitForElement("LogText");
string contents = kind == SampleKind.WinUI ? text.Name : text.AsTextBox().Text;
Assert.Contains("Latest entry marker", contents, StringComparison.Ordinal);
Assert.StartsWith("Showing a bounded log tail;", contents, StringComparison.Ordinal);
Assert.True(contents.Length < 1024 * 1024);
session.WaitFor(
() => text.Patterns.Text.Pattern.GetVisibleRanges().Any(range => range.GetText(-1).Contains("Latest entry marker", StringComparison.Ordinal)) ? text : null,
"the newest log entry to be visible with Follow enabled");
AutomationElement notice = session.WaitForElement("LogTruncationNotice");
Assert.Equal("Older log content omitted", notice.Name);
Assert.False(notice.IsOffscreen);
Assert.False(session.App.HasExited);
// Optional local review evidence; images are restricted to the test app's window.
string? screenshots = Environment.GetEnvironmentVariable("APPKIT_UITEST_SCREENSHOT_DIR");
if(!string.IsNullOrWhiteSpace(screenshots))
{
Directory.CreateDirectory(screenshots);
AutomationElement window = session.TopLevelWindows().Single(w => string.Equals(w.Name, "AppKit Sample — Activity log", StringComparison.Ordinal));
window.AsWindow().SetForeground();
Rectangle bounds = window.BoundingRectangle;
using CaptureImage capture = Capture.ElementRectangle(window, new System.Drawing.Rectangle(12, 12, bounds.Width - 24, bounds.Height - 24));
capture.ToFile(Path.Combine(screenshots, $"LogTail-{kind}.png"));
}
}
/// <summary>ThemeChoice: is saved.</summary>
[Theory]
[InlineData(SampleKind.WinUI)]
@@ -187,4 +187,4 @@
}
}
}
}
}
+19 -2
View File
@@ -8,9 +8,26 @@
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"koftwentytwo.appkit": {
"type": "Project"
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
}
}
}
}
}