WIP: feat: complete AppKit foundation and release readiness #25
No files matched your search
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-stryker": {
|
||||
"version": "5.0.0",
|
||||
"commands": [
|
||||
"dotnet-stryker"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. -->
|
||||
@@ -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: /
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
security:
|
||||
needs: verify
|
||||
uses: ./.github/workflows/security.yml
|
||||
zizmor /use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' ## 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
|
||||
zizmor /use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' ## 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
|
||||
zizmor /use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' ## 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
|
||||
zizmor /use GitHub's dedicated self-repository syntax: use '$/...' instead of './...' ## 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
|
||||
|
||||
@@ -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 });
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -292,6 +292,7 @@ FakesAssemblies/
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
testResults.xml
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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*'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
];
|
||||
Generated
+1074
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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's install and update hooks first, then creates the WPF application with AppKit'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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)]
|
||||
|
||||
@@ -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'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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user
zizmor /
use GitHub's dedicated self-repository syntax: use '$/...' instead of './...'
Show more details