diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..96ccac3 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "5.0.0", + "commands": [ + "dotnet-stryker" + ] + } + } +} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ce95f83..46b0895 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 70ef038..50c91ce 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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: / diff --git a/.github/scripts/review-gate.cjs b/.github/scripts/review-gate.cjs new file mode 100644 index 0000000..b43b20f --- /dev/null +++ b/.github/scripts/review-gate.cjs @@ -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 = ``; + 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 }; diff --git a/.github/scripts/review-gate.test.cjs b/.github/scripts/review-gate.test.cjs new file mode 100644 index 0000000..cc4b477 --- /dev/null +++ b/.github/scripts/review-gate.test.cjs @@ -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: `\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); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5684dc0..f417967 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 85be0ae..a81d096 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..5cb5b53 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -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 diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml index fb813c3..2b3fd16 100644 --- a/.github/workflows/pr-gates.yml +++ b/.github/workflows/pr-gates.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9291aa2..dc55170 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 + permissions: + contents: read + security-events: write + + codeql: + needs: verify + uses: ./.github/workflows/codeql.yml + 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 + permissions: + contents: read + + mutation: + needs: verify + uses: ./.github/workflows/mutation.yml + 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 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..3361c1b --- /dev/null +++ b/.github/workflows/review.yml @@ -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 }); diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml new file mode 100644 index 0000000..83f3753 --- /dev/null +++ b/.github/workflows/scripts.yml @@ -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 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 79d936c..36f0a5a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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 diff --git a/.gitignore b/.gitignore index 22e151b..0af82fe 100644 --- a/.gitignore +++ b/.gitignore @@ -292,6 +292,7 @@ FakesAssemblies/ # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ +testResults.xml # Visual Studio 6 build log *.plg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7de635c --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33d1946 --- /dev/null +++ b/AGENTS.md @@ -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 `/`. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6880754 --- /dev/null +++ b/CHANGELOG.md @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index b9e36c2..e7035f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `/` → 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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad73a79..8b1e2a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.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 diff --git a/Directory.Build.props b/Directory.Build.props index 25b5fe8..7cea129 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -54,7 +54,7 @@ -p:Version=, which overrides both parts. SourceLink appends the git commit as "+" on AssemblyInformationalVersion. --> - 0.1.0 + 0.2.0 dev diff --git a/Directory.Packages.props b/Directory.Packages.props index b870b05..fcd2d1d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..cf3f495 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -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' + } + } +} diff --git a/README.md b/README.md index 6df03ae..c613871 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/build/ArtifactSigning.Tests.ps1 b/build/ArtifactSigning.Tests.ps1 new file mode 100644 index 0000000..794f30e --- /dev/null +++ b/build/ArtifactSigning.Tests.ps1 @@ -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' + } +} diff --git a/build/Assert-Coverage.Tests.ps1 b/build/Assert-Coverage.Tests.ps1 new file mode 100644 index 0000000..8b4bf68 --- /dev/null +++ b/build/Assert-Coverage.Tests.ps1 @@ -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 = @" + + + +"@ + 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' + } +} diff --git a/build/Assert-Coverage.ps1 b/build/Assert-Coverage.ps1 index 1f7924c..b1950a8 100644 --- a/build/Assert-Coverage.ps1 +++ b/build/Assert-Coverage.ps1 @@ -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." diff --git a/build/Get-ArtifactSigningConfiguration.ps1 b/build/Get-ArtifactSigningConfiguration.ps1 new file mode 100644 index 0000000..5c5ce9e --- /dev/null +++ b/build/Get-ArtifactSigningConfiguration.ps1 @@ -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." diff --git a/build/Invoke-MutationTests.ps1 b/build/Invoke-MutationTests.ps1 new file mode 100644 index 0000000..e6f69e7 --- /dev/null +++ b/build/Invoke-MutationTests.ps1 @@ -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 +} diff --git a/build/Invoke-WindowsSigning.ps1 b/build/Invoke-WindowsSigning.ps1 new file mode 100644 index 0000000..7303b33 --- /dev/null +++ b/build/Invoke-WindowsSigning.ps1 @@ -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 } +} diff --git a/build/New-ArtifactSigningIdentity.ps1 b/build/New-ArtifactSigningIdentity.ps1 new file mode 100644 index 0000000..2bf2067 --- /dev/null +++ b/build/New-ArtifactSigningIdentity.ps1 @@ -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 diff --git a/build/WindowsSigning.Tests.ps1 b/build/WindowsSigning.Tests.ps1 new file mode 100644 index 0000000..9f46998 --- /dev/null +++ b/build/WindowsSigning.Tests.ps1 @@ -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 ' -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*' + } +} diff --git a/build/WindowsSigning.psm1 b/build/WindowsSigning.psm1 new file mode 100644 index 0000000..4f070b5 --- /dev/null +++ b/build/WindowsSigning.psm1 @@ -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 diff --git a/build/stryker-config.json b/build/stryker-config.json new file mode 100644 index 0000000..01a6f1e --- /dev/null +++ b/build/stryker-config.json @@ -0,0 +1,9 @@ +{ + "stryker-config": { + "reporters": ["cleartext", "html", "json"], + "mutation-level": "Standard", + "concurrency": 2, + "break-on-initial-test-failure": true, + "coverage-analysis": "perTest" + } +} diff --git a/coverage.runsettings b/coverage.runsettings index de28395..bfd506b 100644 --- a/coverage.runsettings +++ b/coverage.runsettings @@ -1,7 +1,7 @@ +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. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 4f9450c..a08e5c1 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -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 | `_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 | diff --git a/docs/testing/mutation-testing.md b/docs/testing/mutation-testing.md new file mode 100644 index 0000000..5222206 --- /dev/null +++ b/docs/testing/mutation-testing.md @@ -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. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b676f95 --- /dev/null +++ b/eslint.config.mjs @@ -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" }, + }, + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..02ba372 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1074 @@ +{ + "name": "appkit-repository-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appkit-repository-tools", + "devDependencies": { + "@eslint/js": "10.0.1", + "@stylistic/eslint-plugin": "5.10.0", + "eslint": "10.12.0" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8a48cb --- /dev/null +++ b/package.json @@ -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" +} diff --git a/samples/AppKit.Sample.WinUI/App.xaml.cs b/samples/AppKit.Sample.WinUI/App.xaml.cs index 5525c29..9e7b660 100644 --- a/samples/AppKit.Sample.WinUI/App.xaml.cs +++ b/samples/AppKit.Sample.WinUI/App.xaml.cs @@ -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 /// The app-wide activity log. internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths); + /// The shared structured logger, including scopes. + internal static ILogger Logger { get; } = Log.AsLogger(); + /// Installs the crash net and applies the saved theme; WinUI allows setting the application theme only here, before any window exists. @@ -34,8 +38,14 @@ public partial class App : Application /// Logs the start and opens the main window. protected override void OnLaunched(LaunchActivatedEventArgs args) { - Log.Info($"{SampleApp.Info.DisplayName} started."); + LogStarted(Logger, SampleApp.Info.DisplayName); _window = new MainWindow(); _window.Activate(); } + + + + /// Records app identity as a field without formatting it into the template. + [LoggerMessage(100, LogLevel.Information, "{AppName} started.")] + private static partial void LogStarted(ILogger logger, string appName); } diff --git a/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs b/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs index fa66971..8e7b38f 100644 --- a/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs +++ b/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs @@ -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 /// Writes an INFO entry, to show logging. 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."; } } + /// Records the sample button interaction. + [LoggerMessage(101, LogLevel.Information, "The user pressed 'Write a log entry'.")] + private static partial void LogUserAction(ILogger logger); + + + + /// Records a handled sample failure with its full exception. + [LoggerMessage(102, LogLevel.Error, "Sample operation failed.")] + private static partial void LogSampleFailure(ILogger logger, Exception exception); + + + /// Throws on purpose, to show that the crash net logs the exception and the app keeps running. private void Throw_Click(object sender, RoutedEventArgs e) { diff --git a/samples/AppKit.Sample.WinUI/Program.cs b/samples/AppKit.Sample.WinUI/Program.cs index ec08886..6603d2b 100644 --- a/samples/AppKit.Sample.WinUI/Program.cs +++ b/samples/AppKit.Sample.WinUI/Program.cs @@ -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(); diff --git a/samples/AppKit.Sample.WinUI/packages.lock.json b/samples/AppKit.Sample.WinUI/packages.lock.json index 17e4c8b..3a5b3b8 100644 --- a/samples/AppKit.Sample.WinUI/packages.lock.json +++ b/samples/AppKit.Sample.WinUI/packages.lock.json @@ -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 @@ } } } -} \ No newline at end of file +} diff --git a/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs b/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs index 39319ea..0d8286b 100644 --- a/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs +++ b/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs @@ -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 /// Writes an INFO entry, to show logging. 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."; } } + /// Records the sample button interaction. + [LoggerMessage(101, LogLevel.Information, "The user pressed 'Write a log entry'.")] + private static partial void LogUserAction(ILogger logger); + + + + /// Records a handled sample failure with its full exception. + [LoggerMessage(102, LogLevel.Error, "Sample operation failed.")] + private static partial void LogSampleFailure(ILogger logger, Exception exception); + + + /// Throws on purpose, to show that the crash net logs the exception and the app keeps running. private void Throw_Click(object sender, RoutedEventArgs e) { diff --git a/samples/AppKit.Sample.Wpf/Program.cs b/samples/AppKit.Sample.Wpf/Program.cs index fba4d59..3523243 100644 --- a/samples/AppKit.Sample.Wpf/Program.cs +++ b/samples/AppKit.Sample.Wpf/Program.cs @@ -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; /// Entry point: Velopack hooks first, then the WPF application. -public static class Program +public static partial class Program { /// The app-wide activity log. internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths); + /// The shared structured logger, including scopes. + internal static ILogger Logger { get; } = Log.AsLogger(); + /// 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. [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()); } + + + + /// Records startup with the app identity as a field. + [LoggerMessage(100, LogLevel.Information, "{AppName} (WPF) started.")] + private static partial void LogStarted(ILogger logger, string appName); } diff --git a/samples/AppKit.Sample.Wpf/packages.lock.json b/samples/AppKit.Sample.Wpf/packages.lock.json index eb15c3e..99eeb69 100644 --- a/samples/AppKit.Sample.Wpf/packages.lock.json +++ b/samples/AppKit.Sample.Wpf/packages.lock.json @@ -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 @@ } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs b/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs index 86dccae..af9a859 100644 --- a/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs +++ b/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs @@ -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; /// so WinUI and WPF apps behave identically and the logic is /// unit-tested. /// -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); } } + + + + /// Records a failed interactive check with its separate error field. + [LoggerMessage(1, LogLevel.Warning, "Update check failed: {Error}")] + private static partial void LogCheckFailed(ILogger logger, string error); + + + + /// Records a failed startup check. + [LoggerMessage(2, LogLevel.Warning, "Startup update check failed: {Error}")] + private static partial void LogStartupCheckFailed(ILogger logger, string error); + + + + /// Records the available version independently of the rendered text. + [LoggerMessage(3, LogLevel.Information, "Update available: v{Version}.")] + private static partial void LogUpdateAvailable(ILogger logger, string version); + + + + /// Records which version is about to be installed. + [LoggerMessage(4, LogLevel.Information, "Installing update v{Version}.")] + private static partial void LogInstalling(ILogger logger, string version); + + + + /// Records the target version and installation failure. + [LoggerMessage(5, LogLevel.Error, "Update to v{Version} failed: {Error}")] + private static partial void LogInstallFailed(ILogger logger, string version, string error); } diff --git a/src/KofTwentyTwo.AppKit.Updates/VelopackStartup.cs b/src/KofTwentyTwo.AppKit.Updates/VelopackStartup.cs index 67d94ba..83a3ba8 100644 --- a/src/KofTwentyTwo.AppKit.Updates/VelopackStartup.cs +++ b/src/KofTwentyTwo.AppKit.Updates/VelopackStartup.cs @@ -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. /// +/// +/// Retained for existing callers. For applications packaged by vpk, call +/// VelopackApp.Build().Run() directly in the entry assembly: vpk's static +/// startup verification cannot recognize this indirect wrapper. +/// public static class VelopackStartup { /// diff --git a/src/KofTwentyTwo.AppKit.Updates/packages.lock.json b/src/KofTwentyTwo.AppKit.Updates/packages.lock.json index b926b1e..9566fd3 100644 --- a/src/KofTwentyTwo.AppKit.Updates/packages.lock.json +++ b/src/KofTwentyTwo.AppKit.Updates/packages.lock.json @@ -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" + } } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs index 28f94b3..30912c4 100644 --- a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs +++ b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs @@ -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); diff --git a/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs b/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs index 23f5ac6..6d27c28 100644 --- a/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs +++ b/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs @@ -5,13 +5,14 @@ using KofTwentyTwo.AppKit.Logging; using KofTwentyTwo.AppKit.Settings; +using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml; namespace KofTwentyTwo.AppKit.WinUI; /// App-level wiring every WinUI app repeats: crash net and theme. -public static class WinUIShell +public static partial class WinUIShell { /// /// 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 + /// Retains exception details and a separate error field for the crash net. + [LoggerMessage(10, LogLevel.Error, "Unhandled UI exception: {Error}")] + private static partial void LogUnhandledException(ILogger logger, Exception exception, string error); + + + /// /// Applies the startup theme. Only valid in the App constructor, before any window /// exists; for live changes use . diff --git a/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json b/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json index f65d9e7..30781e9 100644 --- a/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json +++ b/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json @@ -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 @@ } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs index 1750be9..9753e1c 100644 --- a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs +++ b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs @@ -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(); } } diff --git a/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs b/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs index 2aefdae..e453e88 100644 --- a/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs +++ b/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs @@ -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; /// App-level wiring every WPF app repeats: crash net and theme. -public static class WpfShell +public static partial class WpfShell { /// /// 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. } } + + + + /// Retains the crash-net source, error and full exception independently. + [LoggerMessage(10, LogLevel.Error, "{Source}: {Error}")] + private static partial void LogUnhandledException(ILogger logger, Exception exception, string source, string error); } diff --git a/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json b/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json index 60c076b..bb3114c 100644 --- a/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json +++ b/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json @@ -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 @@ } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit/KofTwentyTwo.AppKit.csproj b/src/KofTwentyTwo.AppKit/KofTwentyTwo.AppKit.csproj index 3c28d64..f0398ab 100644 --- a/src/KofTwentyTwo.AppKit/KofTwentyTwo.AppKit.csproj +++ b/src/KofTwentyTwo.AppKit/KofTwentyTwo.AppKit.csproj @@ -9,6 +9,7 @@ + diff --git a/src/KofTwentyTwo.AppKit/Logging/ActivityLogExtensions.cs b/src/KofTwentyTwo.AppKit/Logging/ActivityLogExtensions.cs new file mode 100644 index 0000000..cae5658 --- /dev/null +++ b/src/KofTwentyTwo.AppKit/Logging/ActivityLogExtensions.cs @@ -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; + +/// Adapts existing activity-log implementations to standard structured .NET logging. +public static class ActivityLogExtensions +{ + /// + /// 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. + /// + public static ILogger AsLogger(this IActivityLog log) + { + ArgumentNullException.ThrowIfNull(log); + return log is NullActivityLog ? NullLogger.Instance : new ActivityLogLogger(log); + } +} diff --git a/src/KofTwentyTwo.AppKit/Logging/ActivityLogLogger.cs b/src/KofTwentyTwo.AppKit/Logging/ActivityLogLogger.cs new file mode 100644 index 0000000..2d0359a --- /dev/null +++ b/src/KofTwentyTwo.AppKit/Logging/ActivityLogLogger.cs @@ -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; + +/// Stores standard logging state without reflection-based serialization or custom template parsing. +internal sealed class ActivityLogLogger : ILogger +{ + private readonly IActivityLog _log; + private readonly LoggerExternalScopeProvider _scopes = new(); + + + + /// Wraps an existing sink; the public extension validates it. + public ActivityLogLogger(IActivityLog log) => _log = log; + + + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => _scopes.Push(state); + + + + /// + public bool IsEnabled(LogLevel logLevel) => logLevel is >= LogLevel.Trace and < LogLevel.None; + + + + /// Formats and writes one event; even malformed state or a failing custom sink cannot escape. + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if(!IsEnabled(logLevel)) + { + return; + } + try + { + string message = formatter(state, exception); + KeyValuePair[] fields = state is IEnumerable> 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> 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. + } + } + + + + /// Writes named fields separately from the original message template. + private static void WriteProperties(Utf8JsonWriter writer, IEnumerable> properties) + { + writer.WriteStartObject(); + foreach(KeyValuePair property in properties) + { + if(!string.Equals(property.Key, "{OriginalFormat}", StringComparison.Ordinal)) + { + writer.WritePropertyName(property.Key); + WriteValue(writer, property.Value); + } + } + writer.WriteEndObject(); + } + + + + /// Preserves scalar JSON types; other values use an invariant string without inspecting object members. + 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; + } + } +} diff --git a/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs b/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs index a25b867..df21e88 100644 --- a/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs +++ b/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs @@ -8,7 +8,9 @@ namespace KofTwentyTwo.AppKit.Logging; /// /// 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 +/// for structured templates and fields; +/// these legacy methods remain available for existing consumers and implementations. /// public interface IActivityLog { diff --git a/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs b/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs index 17678e0..30ce5e6 100644 --- a/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs +++ b/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs @@ -8,4 +8,8 @@ namespace KofTwentyTwo.AppKit.Logging; /// The last lines of a log file and the file length they were read at. /// The tail, oldest first. /// File length in bytes when read; 0 when the file is absent. -public sealed record LogSnapshot(IReadOnlyList Lines, long Length); +public sealed record LogSnapshot(IReadOnlyList Lines, long Length) +{ + /// True when the input byte limit omitted a prefix of the file. + public bool IsTruncated { get; init; } +} diff --git a/src/KofTwentyTwo.AppKit/Logging/LogTail.cs b/src/KofTwentyTwo.AppKit/Logging/LogTail.cs index a3e4a9b..aad250c 100644 --- a/src/KofTwentyTwo.AppKit/Logging/LogTail.cs +++ b/src/KofTwentyTwo.AppKit/Logging/LogTail.cs @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ +using System.Text; using System.Text.RegularExpressions; @@ -19,13 +20,31 @@ public static partial class LogTail + /// Maximum trailing input per read; also bounds a single decoded line. + public const int DefaultMaxBytes = 1024 * 1024; + + + /// /// The last lines of , opened /// shared so the logger can keep appending while it is read. /// public static LogSnapshot Read(string? path, int maxLines = DefaultMaxLines) + => Read(path, maxLines, DefaultMaxBytes); + + + + /// + /// Reads at most 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; identifies it. + /// The byte budget must be between four bytes and . + /// + 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 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(maxLines); + var tail = new Queue(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 + /// Preserves the BOM detection supported by StreamReader, including UTF-32 before UTF-16. + private static (Encoding Encoding, int Preamble, int Width) DetectEncoding(ReadOnlySpan header) + { + if(header.StartsWith([0xFF, 0xFE, 0x00, 0x00])) + { + return (Encoding.UTF32, 4, 4); + } + if(header.StartsWith([0x00, 0x00, 0xFE, 0xFF])) + { + return (new UTF32Encoding(bigEndian: true, byteOrderMark: true), 4, 4); + } + if(header.StartsWith([0xFF, 0xFE])) + { + return (Encoding.Unicode, 2, 2); + } + if(header.StartsWith([0xFE, 0xFF])) + { + return (Encoding.BigEndianUnicode, 2, 2); + } + return header.StartsWith([0xEF, 0xBB, 0xBF]) ? (Encoding.UTF8, 3, 1) : (Encoding.UTF8, 0, 1); + } + + + /// /// 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 /// The text a viewer shows for , with friendly empty states. public static string ToDisplayText(IReadOnlyList lines, bool errorsOnly) + => ToDisplayText(lines, errorsOnly, isTruncated: false); + + + + /// Shows when the input byte limit hides older entries or cuts the first visible line. + public static string ToDisplayText(IReadOnlyList 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; } diff --git a/src/KofTwentyTwo.AppKit/README.md b/src/KofTwentyTwo.AppKit/README.md index 6650f31..32246a5 100644 --- a/src/KofTwentyTwo.AppKit/README.md +++ b/src/KofTwentyTwo.AppKit/README.md @@ -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%\`, relocatable with `_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`** / **`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. diff --git a/src/KofTwentyTwo.AppKit/packages.lock.json b/src/KofTwentyTwo.AppKit/packages.lock.json index d635df9..bffdbee 100644 --- a/src/KofTwentyTwo.AppKit/packages.lock.json +++ b/src/KofTwentyTwo.AppKit/packages.lock.json @@ -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==" } } } -} \ No newline at end of file +} diff --git a/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs b/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs index ffd3fe3..17c104f 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs @@ -35,6 +35,16 @@ public class CreditsTests + /// A missing email does not introduce a leading separator before repository/website credits. + [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)); + } + + + /// The owner profile is host/owner for any repository URL, or just the host when the path is empty. [Theory] [InlineData(null, null)] diff --git a/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs b/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs index 307bcb4..3212124 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs @@ -132,10 +132,16 @@ internal sealed class FakeUpdateService : IUpdateService public int ApplyCalls { get; private set; } + public int CheckCalls { get; private set; } + /// Returns the scripted check result. - public Task CheckAsync(CancellationToken cancellationToken = default) => Task.FromResult(NextCheck); + public Task CheckAsync(CancellationToken cancellationToken = default) + { + CheckCalls++; + return Task.FromResult(NextCheck); + } @@ -152,9 +158,17 @@ internal sealed class FakeUpdateService : IUpdateService /// A scriptable Velopack stand-in. 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; } diff --git a/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs b/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs index 8efb135..13abbd1 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs @@ -37,6 +37,7 @@ public class AppInfoTests { ArgumentException ex = Assert.Throws(() => 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(() => ArgbColor.Parse("red")); + FormatException error = Assert.Throws(() => 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(() => BuildVersion.Describe(null!)); - Assert.Throws(() => BuildVersion.IsPrerelease((Assembly)null!)); + Assert.Equal("assembly", Assert.Throws(() => BuildVersion.Describe(null!)).ParamName); + Assert.Equal("assembly", Assert.Throws(() => BuildVersion.IsPrerelease((Assembly)null!)).ParamName); } diff --git a/tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs b/tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs new file mode 100644 index 0000000..a9a22a4 --- /dev/null +++ b/tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs @@ -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; + +/// Generates hostile settings and log inputs; FsCheck shrinks and reports a replay seed on failure. +public sealed class InputProperties +{ + /// Repair always produces a valid theme and bounded splash, without changing startup choices. + [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); + } + } + + + + /// Arbitrary bytes, including broken UTF-8 and JSON, cannot stop loading preferences. + [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(path, TestSettingsContext.Default.SampleSettings); + SampleSettings loaded = store.Load(); + AssertValid(loaded); + Assert.InRange(loaded.Concurrency, 1, 64); + } + + + + /// Valid JSON with arbitrary values is repaired, and repaired preferences survive a save/load cycle. + [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(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")); + } + + + + /// Malformed log bytes still yield the correct bounded tail, in original order. + [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); + } + + + + /// Checks the public settings contract independently of the repair implementation. + private static void AssertValid(ShellSettings settings) + { + Assert.Contains(settings.Theme, new[] { "System", "Light", "Dark" }, StringComparer.Ordinal); + Assert.InRange(settings.SplashMilliseconds, ShellSettings.MinSplashMilliseconds, ShellSettings.MaxSplashMilliseconds); + } +} diff --git a/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj b/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj index 83dd8d4..47260ec 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj +++ b/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs b/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs index c0ecc6a..4073af2 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ +using System.Text; using KofTwentyTwo.AppKit.Logging; @@ -129,6 +130,20 @@ public sealed class FileActivityLogTests : IDisposable + /// A log directory removed between writes is recreated rather than silently dropping diagnostics. + [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); + } + + + /// ForApp: uses the apps logs folder and id. [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); + } + + + + /// The viewer can read a log while its writer keeps a shared write handle open. + [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); + } + + + + /// Seeking to the bounded tail preserves the final entries of a huge sparse file. + [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); + } + + + + /// The byte budget bounds a single line and avoids allocating a queue for an arbitrary line count. + [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); + } + + + + /// BOM detection and truncated code-unit alignment preserve UTF-8, UTF-16 and UTF-32 in both byte orders. + [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); + } + + + + /// Empty files and BOM-only files contain no phantom entries. + [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(() => LogTail.Read("x", 0)); + Assert.Throws(() => LogTail.Read("x", 1, 3)); + Assert.Throws(() => LogTail.Read("x", 1, LogTail.DefaultMaxBytes + 1)); } @@ -264,6 +391,19 @@ public sealed class LogTailTests : IDisposable + /// Both viewer modes disclose when the byte limit omitted input, including a partial first line. + [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); + } + + + /// NullArguments: throw. [Fact] public void NullArguments_Throw() diff --git a/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs b/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs index 8d61d13..83254e7 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs @@ -36,6 +36,7 @@ public class InMemorySecretVaultTests Assert.Throws(() => vault.Store("", "v")); Assert.Throws(() => vault.Store("k", null!)); Assert.Throws(() => vault.TryRetrieve(null!)); + Assert.Throws(() => vault.TryRetrieve("")); Assert.Throws(() => 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(() => _vault.Store("token", new string('x', 5000))); Assert.Contains("CredWriteW", ex.Message, StringComparison.Ordinal); - Assert.Throws(() => _vault.Store("token", null!)); + Assert.Equal("secret", Assert.Throws(() => _vault.Store("token", null!)).ParamName); } @@ -110,8 +111,12 @@ public sealed class CredentialManagerVaultTests : IDisposable [Fact] public void InvalidTargets_SurfaceNativeErrors() { - Assert.Throws(() => CredentialManagerVault.TryRetrieveTarget("", "k")); - Assert.Throws(() => CredentialManagerVault.DeleteTarget("", "k")); + Win32Exception read = Assert.Throws(() => CredentialManagerVault.TryRetrieveTarget("", "k")); + Assert.Contains("CredReadW", read.Message, StringComparison.Ordinal); + Assert.Contains("'k'", read.Message, StringComparison.Ordinal); + Win32Exception delete = Assert.Throws(() => 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(() => new CredentialManagerVault("x", isWindows: false)); + PlatformNotSupportedException error = Assert.Throws(() => new CredentialManagerVault("x", isWindows: false)); + Assert.Contains("Windows Credential Manager", error.Message, StringComparison.Ordinal); Assert.Throws(() => new CredentialManagerVault(" ")); } } diff --git a/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs b/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs index 20c61f1..0c4529e 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs @@ -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 + /// Invalid settings are repaired in memory and on disk before a later load can mask bad writes. + [Fact] + public void Save_InvalidSettings_SanitizesBeforeWriting() + { + SettingsStore 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()); + } + + + /// Load: corrupt or null file returns defaults. [Theory] [InlineData("{ not json")] diff --git a/tests/KofTwentyTwo.AppKit.Tests/StructuredLoggingTests.cs b/tests/KofTwentyTwo.AppKit.Tests/StructuredLoggingTests.cs new file mode 100644 index 0000000..7f85259 --- /dev/null +++ b/tests/KofTwentyTwo.AppKit.Tests/StructuredLoggingTests.cs @@ -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; + +/// Verifies standard templates, typed fields, scopes, failure containment and file sink behavior. +public sealed class StructuredLoggingTests : IDisposable +{ + private readonly TempDirectory _temp = new(); + + + + /// Deletes the temporary log folder. + public void Dispose() => _temp.Dispose(); + + + + /// Standard .NET templating preserves the original format and typed values independently of rendering. + [Fact] + public void Log_StandardTemplate_PreservesFieldsAndEventIdentity() + { + var sink = new RecordingLog(); + ILogger logger = sink.AsLogger(); + Action logEvent = LoggerMessage.Define( + 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 _)); + } + + + + /// JSON scalars keep their types; complex values have an explicit invariant string fallback. + [Fact] + public void Log_ScalarFields_PreservesTypesAndEscapesText() + { + var sink = new RecordingLog(); + using var nested = JsonDocument.Parse("{\"nested\":true}"); + KeyValuePair[] 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); + } + + + + /// All enabled severities retain the original exception text, even those without a legacy exception overload. + [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(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); + } + } + + + + /// Unstructured states remain readable; nested structured and string scopes are disposed independently. + [Fact] + public void Log_NestedScopes_RetainsFieldsAndDisposalOrder() + { + var sink = new RecordingLog(); + ILogger logger = sink.AsLogger(); + using(logger.BeginScope("outer")) + { + using(logger.BeginScope(new Dictionary(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()); + } + + + + /// Concurrent asynchronous scopes cannot mix operation fields or interleave daily file records. + [Fact] + public async Task Log_ConcurrentAsyncScopes_KeepsWholeRecordsAndIsolatesFields() + { + var sink = new FileActivityLog(_temp.Path, "parallel"); + ILogger logger = sink.AsLogger(); + Action logEvent = LoggerMessage.Define(LogLevel.Information, new EventId(9), "Operation {OperationId}"); + await Task.WhenAll(Enumerable.Range(0, 100).Select(async id => + { + using(logger.BeginScope(new Dictionary(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(); + 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)); + } + } + + + + /// Structured writes use the same daily rollover and retention policy as legacy entries. + [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()); + } + + + + /// Disabled levels bypass formatting and never write an event. + [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); + } + + + + /// A warning without an exception remains one JSON event without an extra blank line. + [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()); + } + + + + /// Formatter errors and custom sink failures cannot escape any supported logging severity. + [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); + } + } + + + + /// Existing null sinks stay disabled; only creating an adapter rejects a missing sink. + [Fact] + public void AsLogger_NullSink_IsDisabledAndNullArgumentIsRejected() + { + Assert.False(NullActivityLog.Instance.AsLogger().IsEnabled(LogLevel.Critical)); + Assert.Throws(() => ActivityLogExtensions.AsLogger(null!)); + } + + + + /// Reads the JSON after either the file prefix or a recording sink's severity. + private static JsonElement ReadRecord(string entry) + { + using var document = JsonDocument.Parse(entry[entry.IndexOf('{', StringComparison.Ordinal)..]); + return document.RootElement.Clone(); + } +} + + + +/// A legacy sink that deliberately violates its contract to exercise the adapter's containment. +internal sealed class ThrowingActivityLog : IActivityLog +{ + public string LogDirectory => ""; + + public string CurrentLogFilePath => ""; + + + + /// + public void Info(string message) => throw new IOException("sink failed"); + + + + /// + public void Warning(string message) => throw new IOException("sink failed"); + + + + /// + public void Error(string message, Exception? exception = null) => throw new IOException("sink failed"); +} diff --git a/tests/KofTwentyTwo.AppKit.Tests/UpdateContextTests.cs b/tests/KofTwentyTwo.AppKit.Tests/UpdateContextTests.cs new file mode 100644 index 0000000..2397b1d --- /dev/null +++ b/tests/KofTwentyTwo.AppKit.Tests/UpdateContextTests.cs @@ -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; + +/// Exercises genuinely asynchronous update completions while a single UI thread pumps its context. +public sealed class UpdateContextTests +{ + /// Check, confirmation and download completions return to the UI context before the next prompt. + [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); + } + + + + /// The UI-independent backend applies after download without requiring the caller's UI context. + [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); + } + + + + /// A bounded single-thread pump; completions are queued so each tested await starts with an incomplete task. + private sealed class PumpedContext : SynchronizationContext, IDisposable + { + private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _callbacks = new(); + + + + /// Queues a callback for the thread running the pump. + public override void Post(SendOrPostCallback d, object? state) => _callbacks.Add((d, state)); + + + + /// Runs an asynchronous operation while pumping callbacks, then restores the test framework's context. + public Task Run(Func 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); + } + } + + + + /// Completes after the next pump iteration, with continuations scheduled rather than inlined. + public Task CompleteLater(T value) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(_ => completion.SetResult(value), null); + return completion.Task; + } + + + + /// Releases the callback queue after the operation has finished. + public void Dispose() => _callbacks.Dispose(); + } + + + + /// An update service whose operations complete asynchronously through the test UI pump. + private sealed class ContextUpdates(PumpedContext context) : IUpdateService + { + public bool IsSupported => true; + + public string? CurrentVersion => "1.0.0"; + + + + /// Offers a version only after the caller has yielded. + public Task CheckAsync(CancellationToken cancellationToken = default) + => context.CompleteLater(UpdateCheckResult.Available("2.0.0")); + + + + /// Returns a recoverable installation failure after the caller has yielded. + public Task DownloadAndApplyAsync(IProgress? progress = null, CancellationToken cancellationToken = default) + => context.CompleteLater("disk full"); + } + + + + /// A backend that completes through the pump but verifies installation proceeds without a UI context. + private sealed class ContextBackend(PumpedContext context) : IUpdateBackend + { + public bool IsInstalled => true; + + public string? CurrentVersion => "1.0.0"; + + public bool Applied { get; private set; } + + + + /// Checks complete after the caller yields. + public Task CheckAsync() => context.CompleteLater("2.0.0"); + + + + /// Downloads complete after the caller yields. + public Task DownloadAsync(Action? progress, CancellationToken cancellationToken) => context.CompleteLater(true); + + + + /// Rejects a UI-context dependency before recording the application. + public void ApplyAndRestart() + { + Assert.Null(SynchronizationContext.Current); + Applied = true; + } + } + + + + /// A dialog adapter that rejects calls from a worker thread or another synchronization context. + private sealed class ContextPrompter(PumpedContext context) : IUserPrompter + { + public int Confirmations { get; private set; } + + public List Messages { get; } = []; + + + + /// Checks context affinity and records the installation failure. + public Task ShowMessageAsync(string title, string message) + { + Assert.Same(context, SynchronizationContext.Current); + Messages.Add(message); + return context.CompleteLater(true); + } + + + + /// Checks context affinity and answers asynchronously, exercising the next continuation too. + public Task ConfirmAsync(string title, string message, string confirmText, string cancelText) + { + Assert.Same(context, SynchronizationContext.Current); + Confirmations++; + return context.CompleteLater(true); + } + } +} diff --git a/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs b/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs index ce5ea7d..f77aa8b 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ +using System.Text.Json; using KofTwentyTwo.AppKit.Updates; @@ -27,6 +28,35 @@ public class UpdateCheckResultTests /// Tests for the update service's guarding and state, over a fake Velopack backend. public class VelopackUpdateServiceTests { + /// Repeated property reads and checks share the same lazy backend rather than recreating an update manager. + [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); + } + + + + /// Reading an unavailable installed version fails closed without throwing into an app's UI. + [Fact] + public void CurrentVersion_BackendThrows_ReturnsNull() + { + var service = new VelopackUpdateService(() => new FakeBackend { VersionFailure = new IOException("missing manifest") }); + Assert.Null(service.CurrentVersion); + } + + + /// NotInstalled: is unsupported. [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")); + } + + + + /// An error wins over a conflicting available version and never prompts an installation. + [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(() => new UpdateCoordinator(_updates, null!, "x")); Assert.Throws(() => new UpdateCoordinator(_updates, prompter, " ")); } + + + + /// Checks rendered compatibility and separate named fields at the existing sink boundary. + 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()); + } + } } diff --git a/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json b/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json index de79af2..7440c86 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json +++ b/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json @@ -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 @@ } } } -} \ No newline at end of file +} diff --git a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs index 5b41f4b..d3101eb 100644 --- a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs +++ b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs @@ -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 /// /// Opens the top-level menu named and invokes the item - /// . A freshly started app can swallow the first + /// . 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. /// 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(); } } } diff --git a/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs b/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs new file mode 100644 index 0000000..78b7e61 --- /dev/null +++ b/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs @@ -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; + +/// Uses physical screen coordinates for UI input and capture, restoring the synchronous test thread afterwards. +internal sealed class DpiAwarenessScope : IDisposable +{ + private readonly nint _previous; + + + + /// Disables DPI virtualization on this thread with per-monitor awareness v2 (Windows 10 1703+). + public DpiAwarenessScope() + { + _previous = SetThreadDpiAwarenessContext(-4); + if(_previous == 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + } + + + + /// Restores the context on the same thread that ran the synchronous UI test. + public void Dispose() => SetThreadDpiAwarenessContext(_previous); + + + + /// Windows' thread-scoped DPI override, supported by the repository's minimum OS. + [DllImport("user32.dll", ExactSpelling = true, SetLastError = true)] + private static extern nint SetThreadDpiAwarenessContext(nint dpiContext); +} diff --git a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs index c3d5d13..e5c0de3 100644 --- a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs +++ b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs @@ -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 + /// Huge logs retain the newest entries and a visible notice even while Follow scrolls to the end. + [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")); + } + } + + + /// ThemeChoice: is saved. [Theory] [InlineData(SampleKind.WinUI)] diff --git a/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json b/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json index 547c595..9dbe0be 100644 --- a/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json +++ b/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json @@ -187,4 +187,4 @@ } } } -} \ No newline at end of file +} diff --git a/tools/BrandTool/packages.lock.json b/tools/BrandTool/packages.lock.json index 655d5df..f35c48a 100644 --- a/tools/BrandTool/packages.lock.json +++ b/tools/BrandTool/packages.lock.json @@ -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" + } } } } -} \ No newline at end of file +}