diff --git a/.github/agents/powershell-developer.agent.md b/.github/agents/powershell-developer.agent.md index de5b25f..1f9faa5 100644 --- a/.github/agents/powershell-developer.agent.md +++ b/.github/agents/powershell-developer.agent.md @@ -18,6 +18,7 @@ Implement PowerShell command and helper changes in the PSModuleUpdate style. - Keep one externally called function per file and match the file name to that function. `src/public/*.ps1` files keep exactly one top-level public function; when a public command needs supporting logic, extract it to `src/private/` instead of adding more top-level functions to the public file. In `src/private/`, additional related functions may stay only as same-file top-level support helpers called by that file's entry function, and PowerShell functions must not declare nested functions inside their bodies. - Use `.github/instructions/code-quality-matrix.instructions.md` as the best-effort source-code maintainability guidance while shaping `src/**/*.ps1`. - Use `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth while changing PowerShell code or analyzer helpers. +- Follow the CI parity and readiness gate in `.github/instructions/repository-conventions.instructions.md`: inspect affected workflows before implementation, review test portability and dependency delivery, and distinguish local checks from exact-revision hosted matrix results before handoff. - Add or update tests that follow the naming convention `tests/public/.Tests.ps1` for public commands and source-mirrored paths such as `tests/private//.Tests.ps1` for private helpers, one test file per source file, and keep valid PlatyPS-compatible help docs for the changed behavior using the Microsoft.PowerShell.PlatyPS cmdlets instead of hand-written help structure. - Every new public entry point must add its matching help file in the same change. - Before handoff, review every changed or generated text file and normalize it to exactly one trailing newline with no extra blank lines at the bottom. diff --git a/.github/agents/test-engineer.agent.md b/.github/agents/test-engineer.agent.md index 3698555..4fdd2ac 100644 --- a/.github/agents/test-engineer.agent.md +++ b/.github/agents/test-engineer.agent.md @@ -36,7 +36,7 @@ Improve or maintain the repository's Pester coverage, coverage-gate behavior, an ## Constraints -- 1. Use `Invoke-NovaTest` when the change affects only in-memory logic with no module-load or file-system dependency. 2. Escalate to `Test-NovaBuild` when the change affects module manifest, exported commands, or build artifacts. 3. Run the full repo quality loop only when step 2 passes and a quality tooling regression is still open. +- Start with focused `Invoke-NovaTest` validation and use `Test-NovaBuild` for built-module/integration behavior. Run the required full repository quality loop for code or test changes regardless of whether a quality tooling regression remains open. Follow the CI parity and readiness gate in `.github/instructions/repository-conventions.instructions.md`; local success does not qualify other platforms or dependency environments. - Passing tests are not enough if any quality tooling hotspot score for a changed file worsens compared to the baseline on the default branch. - If an existing fixture or support helper in `tests/*TestSupport.ps1` covers at least 80% of the setup needed, extend it rather than creating a new file. Only create a new support file when no existing helper addresses the scenario. - Do not group unrelated source files into one broad test file when mirrored `tests/public`, `tests/private`, or `tests/classes` ownership is possible. For source files outside `public`, `private`, and `classes` subdirectories, place tests in a `tests//` folder that mirrors the source path. If no matching subdirectory exists, create it rather than placing the test in the root `tests/` folder. @@ -55,6 +55,7 @@ Improve or maintain the repository's Pester coverage, coverage-gate behavior, an - The touched tests are readable and low-duplication. - Validation uses `Invoke-NovaTest` for unit execution and `Test-NovaBuild` for build-validation integration execution. - Validation and quality tooling implications are addressed. +- Local and hosted validation evidence is reported separately under the repository CI parity gate; every required matrix lane and aggregate gate must pass for the exact revision before claiming CI or PR readiness. - The pre-commit quality tooling safeguard is clean before the work is treated as commit-ready when local quality tooling is available. If local quality tooling is unavailable, document the gap in a `# TODO: verify quality tooling` comment on the PR description and do not block the commit, but treat the item as open. ## Must not do diff --git a/.github/instructions/repository-conventions.instructions.md b/.github/instructions/repository-conventions.instructions.md index aa81d74..2de1dca 100644 --- a/.github/instructions/repository-conventions.instructions.md +++ b/.github/instructions/repository-conventions.instructions.md @@ -45,6 +45,25 @@ If quality tooling is unavailable locally, continue with the normal repository v If the only files changed are `.md` files and none of those files contain embedded code blocks that are executed as part of a test or build step, executable validation may be skipped. +## CI parity and readiness + +- Before changing code or tests, inspect the active `.github/workflows/` definitions and their owning `scripts/build/ci/` helpers. Identify the required OS, architecture, PowerShell versions, dependency provisioning and checks affected by the change; shared tests can break a platform even when production code did not change. +- Run the focused checks and the required local quality loop. Local `run.ps1` success is not matrix qualification. Use the configured lane entrypoint on a matching host/runtime where available, preserving dependency isolation and failure semantics; never spoof runner identity or bypass host guards. +- Review changed tests for portability, including path separators, casing, filesystem ordering and platform-specific commands. Build expected filesystem paths with `Join-Path` and compare the intended path contract, not a string containing the development host's separator. Preserve exact identity and side-effect assertions. +- Before raising a dependency minimum, verify that CI's configured provider and version-selection rules can supply a compatible artifact. Record the actual imported version and path in the test process, not just the launcher's installed-module inventory; a locally built or installed sibling module does not prove availability on a clean runner. An unavailable dependency blocks consumer readiness until an upstream release or explicit CI artifact-delivery change is separately authorized. Do not lower the minimum, add fallback imports, publish packages or modify global module installations to hide the gap. +- Report local and hosted evidence separately: tested revision/content state, OS/architecture, actual PowerShell and dependency versions, exact commands, test/coverage results and relevant run/job URLs. Mark unavailable hosts/runtimes, skipped required tests, interrupted commands and missing artifacts as unverified, not passed. +- Inspect every required hosted job and the aggregate gate for the exact revision and run attempt before claiming CI or PR readiness. An earlier revision's green run, a green quality job or four passing lanes cannot qualify a failing six-lane matrix. Uncommitted fixes remain pending hosted verification; a local handoff must say so. +- Trace failures to the first failing assertion or provisioning step before changing code. Keep regression assertions and thresholds intact; do not skip Windows tests, weaken comparisons, retry until green without diagnosis, or treat downstream missing-phase/artifact errors as the primary defect. + +For PSModuleUpdate, `.github/workflows/ci.yml` and `scripts/build/ci/CiLanes.json` +define six required platform/runtime lanes. Reproduce applicable local lanes with +the absolute selected PowerShell executable and +`./scripts/build/ci/Invoke-PlatformLane.ps1 -LaneId ` on a matching host. +Preserve its owned dependency root, runtime/test evidence and cleanup; do not +substitute ambient modules or direct Pester. Both Windows lanes need actual +Windows execution; macOS/Linux results cannot establish their success. Check +`ci-gate` and the separate test/coverage workflow for the tested revision. + ## Documentation review - Review `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, and `RELEASE_NOTE.md` after any change that adds, removes, or alters public-facing behavior, commands, parameters, or documented workflows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69947ce..5179272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,15 @@ name: Test platform matrix on: - pull_request: - branches: [develop, main] push: - branches: [develop, main] + branches-ignore: + - main + pull_request: + branches: + - main + - develop + schedule: + - cron: "0 3 * * *" workflow_call: workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c675eb..d94627b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- Use native PowerShellGet staging and `Update-Module` for existing + installations without Force or an install fallback, while retaining + PSResourceGet discovery, exact target/source validation, consent and recovery. + A worker handoff preserves the original installation lock during mutation. + Operation-owned repository initialization handles defaults populated lazily + during native registration, preventing intermittent pre-installation failure + without changing the user's repositories. + Native updates use the alphanumeric prerelease labels generated by + Gerber/NovaModuleTools; dotted labels are outside this provider's install + capabilities. Historical qualification does not establish untested platform + support. + ### Deprecated ### Removed ### Fixed +- Declare Microsoft.PowerShell.PSResourceGet 1.2.0 as PMU's exact runtime + dependency so generated module manifests enforce the qualified provider + baseline before PMU commands run. +- Linux worker ownership checks no longer reject a live approving process + because of inconsistent cross-process timestamps. Exact PID-reuse protection + remains enabled. Legacy Linux repository receipts require explicit recovery + rather than automatic cleanup. +- CI dependency provisioning initializes a missing default PSGallery + registration before downloading and removes only a registration it explicitly + created afterward, preserving existing sources and trust. Package downloads have + five bounded attempts against the same source, destination and pinned + versions; exhausted downloads and isolated-import failures still fail CI + and clean up the owned dependency directory. +- Forward the complete planned target version unchanged at the provider + boundary instead of wrapping it in NuGet range brackets. + Bracketed prerelease targets could be discovered but failed during staging + from PSGallery. Exact target verification, consent and dependency checks + remain enabled. +- Fixed installed-copy discovery failing before repository lookup when a legacy + flat installation leaves resource directories beside numeric version folders. + Ordinary resource folders are excluded, while numeric folders with missing + manifests and manifest-bearing invalid layouts still fail validation. No + installation is migrated or removed. + ### Security ## [0.1.0] - 2026-10-24 @@ -62,4 +98,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Pass the owned temporary dependency root between `Run Tests` workflow steps so NovaModuleTools is discoverable for CI and changelog validation, with ownership-validated cleanup even after a failed consumer step. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a50c83f..3c26385 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ Thank you for contributing. Keep changes small, reviewable, and easy to validate. +The unreleased native-update transition is not covered by historical G-4A3 +acceptance. Read the [current qualification and native provider scope](docs/architecture/TESTING.md#native-update-transition) +before changing its provider, lifecycle tests, or release requirements. + G-4A3's [accepted closure](docs/architecture/G4A3_CLOSURE.md) is separate from future platform qualification. Review the proposed [cross-platform strategy](docs/architecture/CROSS_PLATFORM_TESTING.md) before implementing testing infrastructure; its matrices and roadmap are not @@ -36,6 +40,11 @@ Before opening a pull request: ## Agentic Copilot workflow +Follow the [CI parity and readiness gate](.github/instructions/repository-conventions.instructions.md#ci-parity-and-readiness) +for every implementation or test change. A local pass is not a six-lane matrix +pass; report the tested runtime/dependencies and any pending or failing hosted +jobs for the exact revision. + Recommended flow: 1. **Design** @@ -63,6 +72,14 @@ PSResourceGet repository state is preserved by the provider fixtures, not by assuming that `HOME` or `PSModulePath` redirects registrations. Unexpected skips and incomplete XML/log/runtime evidence fail the lane. +Dependency provisioning checks that PSGallery points to its official endpoint. +If explicit native default registration is needed, it is removed afterward only +if its source and trust remain unchanged; existing registrations are not changed. +Each package download has at most three attempts with the same +source, version selection and owned destination. Exhausted retries or a failed +isolated import probe still fail provisioning and remove its owned dependency +directory. + The separate `Run Tests` workflow also saves dependencies to an owned temporary root. Each PowerShell consumer step uses the install step's `moduleRoot` output to set an isolated `PSModulePath`; installed modules and imports do not carry diff --git a/README.md b/README.md index 1754b53..5e5597d 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,22 @@ on PMU or contain any PMU-specific code. PSModuleUpdate separates read-only discovery from an explicitly approved installation. It targets PowerShell 7.4+ and uses -`Microsoft.PowerShell.PSResourceGet` 1.2.0 as its qualified provider baseline. -It never persistently configures consumer repositories, stores credentials, -elevates privileges, or reloads the updated module. +`Microsoft.PowerShell.PSResourceGet` 1.2.0 for discovery and declares that exact +version as a module dependency. The unreleased update implementation uses +PowerShellGet 2.2.5 and PackageManagement 1.4.8.1 for staging and native +`Update-Module`, without Force or an `Install-Module` fallback. It never +persistently configures consumer repositories, stores credentials, elevates +privileges, or reloads the updated module. + +**Native prerelease scope:** use provider-compatible labels such as `preview1` +or `preview03`, matching the Gerber/NovaModuleTools release workflow. +PowerShellGet 2.2.5 rejects dotted labels such as `preview.2`; those labels remain +valid for PMU's semantic comparison but are outside this native update scope. +No Force, beta provider or alternate installer is introduced to bypass that +limitation. See +[current qualification](docs/architecture/TESTING.md#native-update-transition) +for successful Gallery evidence and the remaining gates. Historical G-4A3 +acceptance does not qualify this replacement implementation. Pass an installed module object, not just its name. The target need not be the caller or already loaded: @@ -58,10 +71,19 @@ not silently create a copy in another scope or fall back to CurrentUser when AllUsers is not writable. Run in an appropriately authorized shell when system permissions are necessary; PMU never requests elevation. +Installed-copy discovery ignores ordinary resource directories, including +`en-US` and `resources`, beside numeric version directories. A legacy flat +copy may coexist with a versioned installation without blocking discovery; +PMU does not migrate or select that flat copy as a versioned installation. +Numeric version directories and other directories containing the target +module's manifest remain subject to manifest and layout validation. + `IncludePrerelease` considers stable and prerelease releases together. Complete versions use semantic prerelease ordering: `preview.10` follows `preview.2`, and a stable release follows prereleases with the same numeric base. Optional policy fields are `ExpectedRepositoryUri` and `VersionRange`. +Planning support does not imply that the current native provider can install +every selected version; the native label restriction above still applies. ### Private repositories @@ -100,21 +122,27 @@ a new plan after restarting the shell or changing installation/repository state. Repository selection is always caller-owned and source-bound. PMU never searches another feed just because it contains the same package name. PSGallery, public NuGet v3 repositories such as NuGet.org, and authenticated private -NuGet v3 repositories use the same PSResourceGet integration. General .NET +NuGet v3 repositories use the same PSResourceGet discovery integration. +Native installation has separate source/protocol qualification requirements; +successful discovery alone does not establish update support. General .NET NuGet packages are not installable PowerShell modules merely because they can be discovered or downloaded. Nexus is expected to work with compatible NuGet v3 capabilities, but no live Nexus qualification is implied. ## Replacement, recovery, and restart -Same-base transitions such as `2.0.0-preview.1` to `2.0.0-preview.2` and then -`2.0.0` require staged replacement because those versions share a numeric +Same-base transitions such as `2.0.0-preview1` to `2.0.0-preview2` and then +`2.0.0` share a numeric installation directory. PMU validates the staged identity and GUID, preserves a verified backup outside the Modules tree, records recovery state, and verifies the installed file tree. Provider success alone is not proof of installation. +Equivalent dotted-label installation is not part of the Gerber/Nova native +update contract. -A cooperating PMU process holds an exclusive filesystem lock throughout the -operation. Interrupted replacements are checked on the next approved +A cooperating PMU process holds an exclusive filesystem lock during mutation. +The native worker acquires the same original installation lock through a +validated handoff; recovery cannot proceed while that worker still owns it. +Interrupted replacements are checked on the next approved installation attempt. Recoverable failures trigger best-effort rollback; unsafe or unsuccessful recovery stops further installation and preserves available evidence. This is not an atomic installation or a power-loss diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index 8291c3a..fd26a4f 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -22,10 +22,30 @@ This file summarizes public cmdlet, CLI, configuration, and migration changes fo ### Changed +- Existing-module updates now use native `Update-Module` without Force or an + install fallback. Exact target/source, consent and restart requirements stay + in effect. Prerelease updates use native-compatible labels such as `preview03`; + dotted labels such as `preview.3` are not supported by the native installer. + Temporary repository setup also handles automatically registered defaults + without changing your repository settings. + ### Deprecated ### Removed ### Fixed +- PMU now declares its qualified PSResourceGet 1.2.0 runtime dependency. +- Linux updates no longer falsely reject a live approving process. Legacy + Linux repository receipts require + [authorized manual review](docs/architecture/RECOVERY.md#interrupted-repository-transport) + rather than automatic cleanup. +- Contributor CI setup handles a missing Gallery registration and temporary + package download failures without changing the selected dependency versions. +- Updates now request the exact selected version in the form accepted by the + download provider, including prereleases such as `0.1.1-preview01`. + Dependencies must still resolve successfully before installation can finish. +- Update checks no longer fail merely because `en-US` or `resources` folders + coexist with a versioned module installation. Existing files are left intact. + ### Security diff --git a/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md b/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md index 5909520..b7dc698 100644 --- a/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md +++ b/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md @@ -52,6 +52,12 @@ two default to null. IncludePrerelease includes stable releases as well. The explicitly selected scope must already contain the matching name and GUID; PMU never creates a shadow installation or changes scope. +Ordinary resource folders beside numeric version directories do not count as +installed copies. This allows a versioned installation to coexist with resource +folders left by a legacy flat installation, without migrating or selecting the +flat copy. Numeric directories and directories containing the target module's +manifest still undergo manifest and layout validation. + The manifest supplies installed prerelease identity. The `Active*` fields describe a matching module actually loaded in the session and are null when the target is unloaded. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index a6205c8..918fd29 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -4,11 +4,11 @@ PSModuleUpdate (PMU) is an independent PowerShell 7.4+ module that plans and applies an update for one explicitly identified PowerShell module. It is a thin -orchestration layer over `Microsoft.PowerShell.PSResourceGet`, not a package +orchestration layer over standard PowerShell module providers, not a package manager, repository registry, credential store, background updater, or general installed-module inventory. -This document is the implementation architecture for G-4A3. It transfers the +The original architecture below records G-4A3. It transfers the approved G-4A1 contract and the superseding G-4A2-FIX2 decisions into the PMU repository. Source, tests, and command help now exist; this architecture is not a substitute for executed qualification. G-4A3 is now functionally complete @@ -16,6 +16,38 @@ and owner-accepted within its qualified scope; see [closure evidence and accepted findings](G4A3_CLOSURE.md). Remaining environment qualification is a separate phase, not a universal production-readiness claim. +### Unreleased native-update transition + +Discovery continues through PSResourceGet 1.2.0. Mutation now uses the runtime's +PowerShellGet 2.2.5 and PackageManagement 1.4.8.1 through a disposable native +worker. `Save-Module` stages the approved target and `Update-Module` updates the +existing selected installation. Production does not call `Install-Module`, +use Force, or hot reload the target. + +The worker uses owned provider configuration/cache/temp roots without changing +the selected CurrentUser or AllUsers destination. A handoff transfers the +original installation lock, not a second worker-only lock. After acquisition, +the worker revalidates the approving process identity, journal transaction, +state/hash and selected installation before invoking native mutation. +The parent must reacquire ownership before interpreting results or recovering. +Requests and credentials travel over redirected stdin, not process arguments. + +Exact full-version pinning remains required. The only qualified ceiling-only +case is same-numeric-base promotion to the approved stable release; it omits +`RequiredVersion` and `AllowPrerelease` and uses that stable `MaximumVersion`. +This exception must not be generalized to arbitrary prerelease or numeric +targets. + +The native update scope uses the alphanumeric prerelease labels generated by +Gerber/NovaModuleTools. PowerShellGet 2.2.5 cannot install dotted labels such +as `preview.2`; the owner confirmed that those labels are not required for +this workflow. Semantic comparison support does not widen that provider +capability. The old PSResourceGet +mutation design below is historical, not a fallback or proof of current +compatibility. [D-002 and D-008](DECISIONS.md) and +[current qualification](TESTING.md#native-update-transition) distinguish the +chosen mechanism from remaining platform qualification. + The historical evidence remains owned by the read-only sibling Gerber repository: diff --git a/docs/architecture/DECISIONS.md b/docs/architecture/DECISIONS.md index c95b8e0..06a7225 100644 --- a/docs/architecture/DECISIONS.md +++ b/docs/architecture/DECISIONS.md @@ -26,13 +26,20 @@ An installed target need not be the caller, be loaded, declare PMU in `RequiredModules`, or contain PMU-specific code. Consumer-specific discovery, configuration, scheduling, and UI remain consumer responsibilities. -## D-002 — Standard provider foundation (DECIDED) +## D-002 — Standard provider foundation (native transition; QUALIFICATION GAP) -PMU uses `Microsoft.PowerShell.PSResourceGet` rather than implementing feed -transport, package resolution, or Azure authentication. Version 1.2.0 is the -qualified behavioral baseline. Compatible later stable 1.x versions require -explicit matrix evidence; PMU never silently switches provider versions or -falls back to PowerShellGet. +The original G-4A3 implementation used PSResourceGet 1.2.0 for discovery and +mutation. The approved unreleased direction retains that discovery provider +but uses native PowerShellGet `Update-Module`, with NovaModuleTools' established +Force-free behavior as the reference. PowerShellGet 2.2.5 and PackageManagement +1.4.8.1 are the current native implementation baseline. + +PMU must not add an `Install-Module` production fallback, Force, custom NuGet +transport, or a silently substituted provider. Exact target/source, scope, +consent, verification and recovery remain requirements. The transition is not +qualified merely because the original PSResourceGet implementation was accepted. +PowerShellGet's dotted-prerelease limitation is explicit and outside the +Gerber/Nova update scope; see D-008. ## D-003 — Two-command public surface (DECIDED) @@ -105,22 +112,31 @@ one exact target; later publication does not change it. Four-component PowerShell versions are preserved and compared without blindly casting them to `SemanticVersion`. Unsupported forms fail explicitly. -## D-008 — Same-base progression through Strategy C (DECIDED; supersedes G-4A2) +## D-008 — Same-base progression (native-compatible scope DECIDED) Normal release progression must support: ```text -2.0.0-preview.1 -> 2.0.0-preview.2 -> 2.0.0-preview.3 -> 2.0.0 +2.0.0-preview1 -> 2.0.0-preview2 -> 2.0.0-preview3 -> 2.0.0 ``` -Same-numeric-base collisions are not rejected as a class. PMU stages with -`Save-PSResource`, validates identity, creates a recoverable backup and journal, -uses exact `Install-PSResource -Reinstall` only for the collision, verifies the -manifest and tree, and rolls back on recoverable failure. +Same-numeric-base collisions must not be rejected as a class. The original +Strategy C used `Save-PSResource` and selective `Install-PSResource -Reinstall`. +The native transition instead stages with `Save-Module`, validates identity, +creates a recoverable backup/journal and uses `Update-Module` without Force. +Manifest/tree verification and recoverable-failure rollback remain required. + +PowerShellGet 2.2.5 rejects dotted labels before staging or updating. On +2026-10-26 the owner confirmed that NovaModuleTools does not generate those +labels and that they are not required for this update workflow. The offline +fixture therefore uses native-compatible labels while retaining the same +five transitions, exact target/source, license consent, WhatIf, inactive-target +and registration-preservation assertions. General semantic-version comparison +tests retain dotted labels; installation does not promise to support them. The earlier G-4A1/G-4A2 fail-closed collision policy and blanket ban on -`-Reinstall` are **SUPERSEDED**. Raw `-Reinstall` is not exposed publicly or -used indiscriminately. +`-Reinstall` were superseded for original Strategy C. That historical exception +does not authorize Force or an install fallback in the native transition. ## D-009 — GUID continuity (DECIDED) diff --git a/docs/architecture/RECOVERY.md b/docs/architecture/RECOVERY.md index df14d60..782902c 100644 --- a/docs/architecture/RECOVERY.md +++ b/docs/architecture/RECOVERY.md @@ -19,6 +19,28 @@ never part of this record. ## Interrupted operations +### Native worker ownership + +The unreleased native updater transfers the same original installation lock +between parent and worker. Once the worker acquires it, it rechecks the approving +process's PID/start identity, journal transaction/state/hash and selected +installation. A dead approver or a recovery/replacement that wins the handoff +gap invalidates the request before native mutation. + +An active writer retains that lock even if its approver exits or its input pipe +closes. Recovery must acquire the original lock; a timeout is not permission to +kill a potentially writing worker, release its exclusion or start rollback. +The parent reacquires ownership before interpreting results, rollback or +cleanup. Transaction-less historical journals remain subject to normal +recovery validation, but do not authorize a new native handoff. + +Permanent cross-process ownership tests live in +`tests/NativeWorkerLifetime.Tests.ps1`. They run real worker/approver processes +and the real lock/journal/protocol code with a simulated provider boundary. +They do not replace real provider installation or platform qualification. + +### Next approved attempt + On an approved installation attempt, PMU acquires the same installation lock and inspects unfinished recovery state before attempting another update. It validates journal contents and referenced paths rather than following @@ -61,6 +83,16 @@ named receipt and registration, stop concurrent registry writers, and obtain authorized manual review of that individual registration. Never reset the provider XML store or bulk-remove PMU-prefixed names. +Linux process identities now use the kernel's stable start counter instead of +cross-process .NET timestamps. A receipt written by the previous Linux +implementation cannot prove its owner's identity, even after that process +exits. PMU therefore stops with `PMU.RepositoryRecoveryRequired` rather than +automatically deleting the registration. Preserve that individual receipt and +registration for the authorized manual review described above. There is no +public receipt-repair command; a restart, fresh plan, or approved installation +attempt does not itself migrate these receipts. This is separate from +installation-journal recovery. + ## When recovery cannot finish Errors such as `PMU.RecoveryRequired`, `PMU.RecoveryFailed`, or diff --git a/docs/architecture/TESTING.md b/docs/architecture/TESTING.md index c3fb287..687329e 100644 --- a/docs/architecture/TESTING.md +++ b/docs/architecture/TESTING.md @@ -41,12 +41,128 @@ Nova owns discovery, supported Pester selection, StrictMode, and result output. Nova writes unit results to `artifacts/UnitTestResults.xml`. Unit coverage is enabled in `project.json`, targets the authored source paths, and writes JaCoCo -to `artifacts/coverage.xml` with the existing 90 percent target. Integration +to `artifacts/coverage.xml` with the configured 95 percent target. Integration tests remain a separate phase and do not generate the unit coverage report. The test/coverage workflow runs `Invoke-NovaTest` before `Test-NovaBuild` and uploads that report for CodeScene and Codecov; test counts alone do not establish a measured coverage percentage. +### Native update transition + +Current local functional evidence on macOS ARM64 / PowerShell 7.4.21 passes. +This is not complete platform or lifecycle qualification: + +- ScriptAnalyzer: zero findings; build passed. +- Unit tests: 803 passed, zero failed, one existing platform skip; coverage + 97.12% against the unchanged 95% threshold. +- The final ordinary `pwsh -NoLogo -NoProfile -File ./run.ps1` passed all six + integration tests, including five native offline updates; three live-feed + scenarios remained explicitly opted out. Build and formatting passed. + The wrapper selected installed Pester 5.9.1. Original assertions, coverage + thresholds and deadlines remain unchanged. +- The native provider fixture uses alphanumeric labels (`preview1`, + `preview2`, `preview3`) as explicitly accepted for Gerber/Nova. It retains + prerelease progression, same-base stable promotion, numeric upgrades and + four-component versions, with exact-target, license, WhatIf, inactive-target + and registration-state assertions unchanged. Semantic comparison tests still + cover dotted labels; native installation does not claim to accept them. +- Final-built public PMU Gallery qualification on 2026-10-27 updated Gerber + `0.1.1-preview02` to exact `0.1.1-preview03` without Force in a disposable + CurrentUser root. WhatIf passed, all 150 existing dependency files retained + content/timestamps and Gerber was not loaded. All 1,286 monitored workstation + entries remained unchanged, and the fixture was removed after verification. + This is not workstation deployment, published-PMU delivery or hosted evidence. + +`tests/NativeWorkerLifetime.Tests.ps1` permanently covers five actual-process +cases: no GO after approver death, death while waiting for the original lock, +recovery winning the handoff gap, replacement winning that gap and an orphan +writer retaining the original lock until completion. Provider initialization, +selected-installation checks and native writing are simulated in these tests; +real parent identity, lock acquisition, journal tokens and protocol remain +active. Run them with `Invoke-NovaTest -TagFilter PMUWorkerLifetime`. +Filtered success does not satisfy whole-project coverage. + +The official PowerShellGet 3.0.23-beta23 adapter was inspected and probed only +in a disposable qualification child. Its exact same-base dotted update left +the previous prerelease installed. A repository-cache timestamp changed while +content remained identical. It is not a qualified replacement or a new runtime +dependency. No third-party internals were patched. + +`Receive-PMUChildProcess` drains stdout and stderr concurrently before waiting +for exit. Its ordinary deadline remains 45 seconds. The native integration +fixture performs five sequential isolated updates (about 68 seconds measured) +and explicitly receives a 150-second total budget; its functional assertions +are unchanged. Registration snapshots sort by name to avoid treating enumeration +order as a configuration change; the raw store-byte preservation check remains. + +The native transition still requires appropriate provider/lifecycle and +exact-revision hosted matrix evidence. Historical G-4A3 closure and passing unit +tests do not establish unexecuted platform qualification. + +The intermittent native initialization failure was captured and corrected. +Two of four retained diagnostic samples failed with the original worker +exception `PMU.RepositoryChanged`: the owned store contained PSGallery and the +approved source when `Get-NativeRepositorySnapshot` required exactly one. +PowerShellGet could lazily populate PSGallery during approved-source +registration, after PMU's initial owned-store cleanup. Seeding it before that +cleanup did not reproduce the late-registration condition. + +Two permanent `Initialize-NativeRepository` regressions cover late default +population and post-registration normalization failure before trust. Against +the unchanged implementation, the focused selection passed 37 cases and failed +these two intended cases; after normalization immediately following missing-source +registration, all 39 passed. Exact single-source/name/URI checks and independent +trust consent remain intact. Cleanup is restricted to the operation-owned store; +no user registration, provider internals, Force option or install fallback changed. +Four post-fix diagnostic samples passed all 20 native updates, followed by the +ordinary full wrapper and final-built Gallery acceptance above. + +A separate deterministic regression proved that initial native repository +enumeration errors were suppressed and could allow cleanup against partial +results. The mirrored `Initialize-NativeRepositoryStore` test failed before +the fix and passes with terminating enumeration errors, asserting no removal. +All 34 focused repository cases pass; that filtered run intentionally does not +satisfy whole-source coverage. The complete run supplies the coverage above. +That enumeration correction is separate from the late-default fix above. + +A separate ordinary run timed out waiting 30 seconds for an AlternateFeed +concurrency fixture to become ready. An isolated two-process diagnostic reached +both readiness signals in about 1.455 seconds and preserved the original +Installed/AlreadyInstalled assertions; the five focused process cases and a +later complete run passed. The timeout's cause remains unknown. Neither its +deadline nor the assertions were loosened, and a passing rerun is not a fix. + +The all-file Code Health review and readable formatting do not establish +complete SIG compliance. Structural steps separated worker-operation transfer, +recovery-journal identity/progress, result identity/outcome, consumer-repository +selection, owned-registration preparation/verification and planning-target +selection. Further steps separated receipt lifecycle/binding, installation +observations, planning state, manifest/inventory reading, update decisions, +provider parameters, recovery identity, worker bootstrap/environment mapping +and stdin payload encoding. Final steps separated shared plan-source projection, +observed native identity and selected snapshot validation. Flat protocol ordering, +types, privacy and copy semantics remain asserted. Worker input-stream failure, +selected identity/source/AlreadyInstalled and successful transport-lifecycle +characterization passed before the relevant extractions. Assertions were +preserved; cleanup still precedes error translation. + +The refreshed review covers 554 existing authored PowerShell files: 548 score +10.0, five are unscorable and the unchanged five-parameter public API scores +9.68 under its accepted exception. Across 224 source functions, no body exceeds +the repository's 15-line goal. +The conservative count includes declarations, parameters and braces but +excludes blank/comment-only lines; 80 whole declarations remain at 16-22 lines. +No source unit exceeds four branch points under the screen's count of +conditional alternatives, switch arms, loops, catches and Boolean decisions. +These are manual review measurements, not CodeScene metrics or new exceptions. + +The whole-declaration target remains unmet despite compliance with the +repository's body-based rule and the measured scores above. A requested scope +decision was unavailable; no additional exception was assumed. Normal full +quality readiness is therefore not claimed. The native defect diagnosis and +the three remaining body-size refactorings are complete; the stricter manual +target and broader platform/lifecycle qualification remain open. + ### Phase 1 skips and platform evidence The [Phase 1 skipped-test inventory](G4A4/PHASE1_IMPLEMENTATION.md#skipped-test-inventory) diff --git a/project.json b/project.json index 55dc83b..48c5027 100644 --- a/project.json +++ b/project.json @@ -1,7 +1,7 @@ { "ProjectName": "PSModuleUpdate", "Description": "Generic self-update functionality for PowerShell modules.", - "Version": "0.1.0", + "Version": "0.1.1-preview01", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" @@ -10,6 +10,12 @@ "Author": "Stiwi Gabriel Courage", "PowerShellHostVersion": "7.4", "GUID": "cc2a0585-ae0a-49df-a47a-2e70de88428f", + "RequiredModules": [ + { + "ModuleName": "Microsoft.PowerShell.PSResourceGet", + "RequiredVersion": "1.2.0" + } + ], "Tags": [], "ProjectUri": "" }, diff --git a/scripts/build/Invoke-ScriptAnalyzerCI.ps1 b/scripts/build/Invoke-ScriptAnalyzerCI.ps1 index fd3b6d4..332adf2 100644 --- a/scripts/build/Invoke-ScriptAnalyzerCI.ps1 +++ b/scripts/build/Invoke-ScriptAnalyzerCI.ps1 @@ -20,8 +20,8 @@ function Invoke-RepositoryAnalysis { $settingsPath = Join-Path $PSScriptRoot 'PSScriptAnalyzerSettings.psd1' $findings = @(foreach ($path in @(Get-RepositoryAnalysisTarget -ProjectRoot $ProjectRoot)) { - Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $settingsPath -ErrorAction Stop - }) + Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $settingsPath -ErrorAction Stop + }) if ($findings.Count -gt 0) { $findings | Format-List RuleName, ScriptName, Line, Message | Out-Host throw "ScriptAnalyzer reported $($findings.Count) finding(s). Fix them before continuing." diff --git a/scripts/build/Test-TextFileFormatting.ps1 b/scripts/build/Test-TextFileFormatting.ps1 index 1447d40..6ca2bbb 100644 --- a/scripts/build/Test-TextFileFormatting.ps1 +++ b/scripts/build/Test-TextFileFormatting.ps1 @@ -36,14 +36,14 @@ function Get-TextFileFormattingTargetFileList { [Parameter(Mandatory)][string]$ProjectRoot ) - $rootFileList = @(Get-ChildItem -LiteralPath $ProjectRoot -Force -File | Where-Object {Test-TextFileFormattingCandidate -FileInfo $_}) + $rootFileList = @(Get-ChildItem -LiteralPath $ProjectRoot -Force -File | Where-Object { Test-TextFileFormattingCandidate -FileInfo $_ }) $scopedFileList = foreach ($relativePath in @('.github', 'docs', 'scripts', 'src', 'tests')) { $directoryPath = Join-Path $ProjectRoot $relativePath if (-not (Test-Path -LiteralPath $directoryPath -PathType Container)) { continue } - Get-ChildItem -LiteralPath $directoryPath -Recurse -Force -File | Where-Object {Test-TextFileFormattingCandidate -FileInfo $_} + Get-ChildItem -LiteralPath $directoryPath -Recurse -Force -File | Where-Object { Test-TextFileFormattingCandidate -FileInfo $_ } } return @($rootFileList + $scopedFileList | Sort-Object FullName -Unique) @@ -119,7 +119,7 @@ if ($MyInvocation.InvocationName -ne '.') { if (-not $result.IsValid) { $message = @( 'Text file ending violations were found. Each tracked text file must end immediately after exactly one newline terminator, with no blank spacer lines at the bottom.' - @($result.Violations | ForEach-Object {"- $_"}) + @($result.Violations | ForEach-Object { "- $_" }) ) -join [Environment]::NewLine throw $message diff --git a/scripts/build/ci/Assert-CiGate.ps1 b/scripts/build/ci/Assert-CiGate.ps1 index 78dd2d5..8a32641 100644 --- a/scripts/build/ci/Assert-CiGate.ps1 +++ b/scripts/build/ci/Assert-CiGate.ps1 @@ -50,11 +50,16 @@ function Read-CiLaneManifest { foreach ($file in Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter 'lane-manifest.json') { try { $entry = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop - if ($entry -isnot [System.Collections.IDictionary]) { throw 'Manifest must be a JSON object.' } + if ($entry -isnot [System.Collections.IDictionary]) { + throw 'Manifest must be a JSON object.' + } $entry['_artifactDirectory'] = $file.Directory.FullName $entry } catch { - @{ laneId = ''; readError = $_.Exception.Message } + @{ + laneId = '' + readError = $_.Exception.Message + } } } } @@ -68,7 +73,7 @@ function Get-CiLaneManifestFailure { [Parameter(Mandatory)]$RunContext ) - $own = @($Manifest | Where-Object {$_.laneId -eq $LaneId}) + $own = @($Manifest | Where-Object { $_.laneId -eq $LaneId }) if ($own.Count -ne 1) { return "Lane '$LaneId' produced $( if ($own.Count -eq 0) {'no manifest'} else {"$( $own.Count ) manifests"} )." } @@ -81,9 +86,13 @@ function Get-CiLaneManifestFailure { try { $entry = $own[0] $expected = @($RunContext.lanes | Where-Object id -eq $LaneId) - if ($expected.Count -ne 1) { throw 'Lane has no unique expected configuration.' } + if ($expected.Count -ne 1) { + throw 'Lane has no unique expected configuration.' + } $artifactName = "pmu-ci-lane-$($RunContext.runId)-$($RunContext.runAttempt)-$LaneId" - if ((Split-Path -Leaf $entry._artifactDirectory) -cne $artifactName) { throw 'Manifest is not in its current-run named artifact directory.' } + if ((Split-Path -Leaf $entry._artifactDirectory) -cne $artifactName) { + throw 'Manifest is not in its current-run named artifact directory.' + } Assert-CiGateManifestEvidence -Manifest $entry -Expected $expected[0] -RunContext $RunContext -Directory $entry._artifactDirectory } catch { "Lane '$LaneId' evidence is invalid: $($_.Exception.Message)" @@ -99,16 +108,20 @@ function Test-CiGateResult { $RunContext ) - if (-not $RunContext) { $RunContext = Get-CiGateRunContext -LanePath $LanePath } + if (-not $RunContext) { + $RunContext = Get-CiGateRunContext -LanePath $LanePath + } foreach ($name in 'commitSha', 'runId', 'runAttempt') { - if ([string]::IsNullOrWhiteSpace($RunContext.$name)) { return 'Gate requires nonempty current commit, run id and attempt.' } + if ([string]::IsNullOrWhiteSpace($RunContext.$name)) { + return 'Gate requires nonempty current commit, run id and attempt.' + } } $manifest = @(Read-CiLaneManifest -Directory $ManifestDirectory) Get-CiGateJobFailure -JobResult $JobResult foreach ($laneId in $ExpectedLaneId) { Get-CiLaneManifestFailure -LaneId $laneId -Manifest $manifest -CommitSha $RunContext.commitSha -RunContext $RunContext } - foreach ($entry in $manifest | Where-Object {$_.laneId -notin $ExpectedLaneId}) { + foreach ($entry in $manifest | Where-Object { $_.laneId -notin $ExpectedLaneId }) { "Unexpected lane manifest '$( $entry.laneId )'." } } @@ -125,13 +138,13 @@ function Get-CiGateRunContext { } if ($MyInvocation.InvocationName -ne '.') { - $expectedLaneId = @((Get-Content -LiteralPath $LanePath -Raw | ConvertFrom-Json).lanes | ForEach-Object {$_.id}) + $expectedLaneId = @((Get-Content -LiteralPath $LanePath -Raw | ConvertFrom-Json).lanes | ForEach-Object { $_.id }) $failure = @(Test-CiGateResult -JobResult (ConvertTo-CiGateJobResult -NeedsJson $env:NEEDS_JSON) ` -ExpectedLaneId $expectedLaneId ` -ManifestDirectory $ManifestDirectory) if ($failure.Count -gt 0) { - $failure | ForEach-Object {Write-Output "::error title=ci-gate::$_"} + $failure | ForEach-Object { Write-Output "::error title=ci-gate::$_" } exit 1 } diff --git a/scripts/build/ci/CiDependencyProbe.ps1 b/scripts/build/ci/CiDependencyProbe.ps1 index 347b89a..8aa0e3a 100644 --- a/scripts/build/ci/CiDependencyProbe.ps1 +++ b/scripts/build/ci/CiDependencyProbe.ps1 @@ -10,7 +10,9 @@ function Assert-CiDependencyOrigin { foreach ($module in Get-Module) { $owned = $module.ModuleBase.StartsWith($ModuleRoot + [IO.Path]::DirectorySeparatorChar) $builtin = $module.ModuleBase -eq $PSHOME -or $module.ModuleBase.StartsWith($PSHOME + [IO.Path]::DirectorySeparatorChar) - if (-not ($owned -or $builtin)) { throw "Dependency resolved outside isolation: $($module.Name)." } + if (-not ($owned -or $builtin)) { + throw "Dependency resolved outside isolation: $($module.Name)." + } } } diff --git a/scripts/build/ci/CiDependencyRoot.ps1 b/scripts/build/ci/CiDependencyRoot.ps1 index 01ab634..c2cc5ee 100644 --- a/scripts/build/ci/CiDependencyRoot.ps1 +++ b/scripts/build/ci/CiDependencyRoot.ps1 @@ -2,11 +2,20 @@ function New-CiDependencyRoot { [CmdletBinding(SupportsShouldProcess)] param([Parameter(Mandatory)][string]$Path) - if (Test-Path -LiteralPath $Path) { throw "Dependency root already exists: '$Path'." } - if ((Split-Path -Leaf $Path) -notlike 'pmu-ci-modules-*') { throw 'Dependency roots must use the pmu-ci-modules- prefix.' } - if (-not $PSCmdlet.ShouldProcess($Path, 'Create disposable CI dependency root')) { return } + if (Test-Path -LiteralPath $Path) { + throw "Dependency root already exists: '$Path'." + } + if ((Split-Path -Leaf $Path) -notlike 'pmu-ci-modules-*') { + throw 'Dependency roots must use the pmu-ci-modules- prefix.' + } + if (-not $PSCmdlet.ShouldProcess($Path, 'Create disposable CI dependency root')) { + return + } $directory = New-Item -ItemType Directory -Path $Path -ErrorAction Stop - $owner = @{ Path = $directory.FullName; Token = [guid]::NewGuid().ToString('N') } + $owner = @{ + Path = $directory.FullName + Token = [guid]::NewGuid().ToString('N') + } $owner | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $owner.Path '.pmu-dependencies.json') return $owner } @@ -15,10 +24,16 @@ function Get-CiDependencyOwner { param([Parameter(Mandatory)][string]$Path) $directory = Get-Item -LiteralPath $Path -ErrorAction Stop - if ($directory.LinkType) { throw 'A dependency root cannot be a filesystem link.' } + if ($directory.LinkType) { + throw 'A dependency root cannot be a filesystem link.' + } $owner = Get-Content -LiteralPath (Join-Path $Path '.pmu-dependencies.json') -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable - if ($owner.Path -ne $directory.FullName -or $owner.Token -notmatch '^[a-f0-9]{32}$') { throw 'Invalid dependency ownership record.' } - if ($directory.Name -notlike 'pmu-ci-modules-*') { throw 'Invalid dependency root name.' } + if ($owner.Path -ne $directory.FullName -or $owner.Token -notmatch '^[a-f0-9]{32}$') { + throw 'Invalid dependency ownership record.' + } + if ($directory.Name -notlike 'pmu-ci-modules-*') { + throw 'Invalid dependency root name.' + } return $owner } @@ -27,8 +42,12 @@ function Remove-CiDependencyRoot { param([Parameter(Mandatory)][hashtable]$Owner) $actual = Get-CiDependencyOwner -Path $Owner.Path - if ($actual.Token -cne $Owner.Token) { throw 'Dependency ownership changed; refusing cleanup.' } - if (-not $PSCmdlet.ShouldProcess($Owner.Path, 'Remove owned disposable CI dependencies')) { return } + if ($actual.Token -cne $Owner.Token) { + throw 'Dependency ownership changed; refusing cleanup.' + } + if (-not $PSCmdlet.ShouldProcess($Owner.Path, 'Remove owned disposable CI dependencies')) { + return + } Remove-Item -LiteralPath $Owner.Path -Recurse -Force -ErrorAction Stop } diff --git a/scripts/build/ci/CiGateEvidence.ps1 b/scripts/build/ci/CiGateEvidence.ps1 index 7fc6cd4..cf70e02 100644 --- a/scripts/build/ci/CiGateEvidence.ps1 +++ b/scripts/build/ci/CiGateEvidence.ps1 @@ -5,13 +5,21 @@ . (Join-Path $PSScriptRoot 'CiGateSchema.ps1') function Assert-CiGateConfiguration { - param([Parameter(Mandatory)]$Manifest, [Parameter(Mandatory)]$Expected) + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)]$Expected + ) foreach ($field in 'id', 'name', 'runner', 'os', 'architecture', 'runtimeIdentifier', 'powershell') { - if ($Manifest.configuration.$field -cne $Expected.$field) { throw "Lane configuration differs at '$field'." } + if ($Manifest.configuration.$field -cne $Expected.$field) { + throw "Lane configuration differs at '$field'." + } } Assert-CiGateSelectedRuntime -Manifest $Manifest -Expected $Expected - Assert-CiRuntimeRecord -Record @{ Environment = $Manifest.environment; Executable = $Manifest.selectedRuntime.Executable } -Expected $Manifest.selectedRuntime + Assert-CiRuntimeRecord -Record @{ + Environment = $Manifest.environment + Executable = $Manifest.selectedRuntime.Executable + } -Expected $Manifest.selectedRuntime Assert-CiGateRunnerIdentity -Environment $Manifest.environment } @@ -19,47 +27,80 @@ function Assert-CiGateRunnerIdentity { param([Parameter(Mandatory)]$Environment) foreach ($field in 'RunnerOs', 'RunnerArch', 'ImageOs', 'ImageVersion') { - if (-not $Environment.$field) { throw "Missing runner metadata '$field'." } + if (-not $Environment.$field) { + throw "Missing runner metadata '$field'." + } + } + if ($Environment.RunnerEnvironment -ne 'github-hosted') { + throw 'A GitHub-hosted runner is required.' } - if ($Environment.RunnerEnvironment -ne 'github-hosted') { throw 'A GitHub-hosted runner is required.' } } function Assert-CiGateSelectedRuntime { - param([Parameter(Mandatory)]$Manifest, [Parameter(Mandatory)]$Expected) + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)]$Expected + ) $runtime = $Manifest.selectedRuntime - Assert-CiGateFieldMatch -Actual $runtime -Expected @{ OperatingSystem = $Expected.os; Architecture = $Expected.architecture } - if ($runtime.PowerShellChannel -cne $Expected.powershell) { throw 'Selected runtime channel differs from lane configuration.' } - if ($runtime.PowerShellVersion -notmatch '^\d+\.\d+\.\d+$') { throw 'Invalid selected runtime version.' } + Assert-CiGateFieldMatch -Actual $runtime -Expected @{ + OperatingSystem = $Expected.os + Architecture = $Expected.architecture + } + if ($runtime.PowerShellChannel -cne $Expected.powershell) { + throw 'Selected runtime channel differs from lane configuration.' + } + if ($runtime.PowerShellVersion -notmatch '^\d+\.\d+\.\d+$') { + throw 'Invalid selected runtime version.' + } Assert-CiGateRuntimeVersion -Runtime $runtime $absolute = $runtime.Executable -match '^/' - if ($Expected.os -eq 'Windows') { $absolute = $runtime.Executable -match '^[A-Za-z]:[\\/]' } - if (-not $absolute) { throw 'Selected executable path is not absolute.' } + if ($Expected.os -eq 'Windows') { + $absolute = $runtime.Executable -match '^[A-Za-z]:[\\/]' + } + if (-not $absolute) { + throw 'Selected executable path is not absolute.' + } } function Assert-CiGateFieldMatch { - param([Parameter(Mandatory)]$Actual, [Parameter(Mandatory)][hashtable]$Expected) + param( + [Parameter(Mandatory)]$Actual, + [Parameter(Mandatory)][hashtable]$Expected + ) foreach ($name in $Expected.Keys) { - if ($Actual.$name -ne $Expected[$name]) { throw "Qualification evidence differs at '$name'." } + if ($Actual.$name -ne $Expected[$name]) { + throw "Qualification evidence differs at '$name'." + } } } function Assert-CiGateRuntimeVersion { param([Parameter(Mandatory)]$Runtime) - if ([version]$Runtime.PowerShellVersion -lt [version]'7.4.0') { throw 'Runtime is below the supported 7.4 minimum.' } - if ($Runtime.PowerShellChannel -eq 'stable') { return } + if ([version]$Runtime.PowerShellVersion -lt [version]'7.4.0') { + throw 'Runtime is below the supported 7.4 minimum.' + } + if ($Runtime.PowerShellChannel -eq 'stable') { + return + } $pins = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'PowerShellVersions.json') -Raw | ConvertFrom-Json -AsHashtable).channels - if ($Runtime.PowerShellVersion -cne $pins[$Runtime.PowerShellChannel].version) { throw 'Selected runtime differs from the explicit repository pin.' } + if ($Runtime.PowerShellVersion -cne $pins[$Runtime.PowerShellChannel].version) { + throw 'Selected runtime differs from the explicit repository pin.' + } } function Assert-CiGateProvisioning { param([Parameter(Mandatory)]$Manifest) $provision = $Manifest.provisioning - if ($provision.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Missing verified provisioning checksum.' } - if (-not $provision.fileName -or -not $provision.hashSource) { throw 'Incomplete provisioning provenance.' } + if ($provision.sha256 -notmatch '^[a-fA-F0-9]{64}$') { + throw 'Missing verified provisioning checksum.' + } + if (-not $provision.fileName -or -not $provision.hashSource) { + throw 'Incomplete provisioning provenance.' + } $expected = @{ channel = $Manifest.configuration.powershell runtimeIdentifier = $Manifest.configuration.runtimeIdentifier @@ -71,7 +112,10 @@ function Assert-CiGateProvisioning { } function Assert-CiGateTestEvidence { - param([Parameter(Mandatory)]$Manifest, [Parameter(Mandatory)][string]$Directory) + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)][string]$Directory + ) foreach ($name in 'unit', 'integration') { $summary = Get-CiTestResultSummary -Path (Join-Path $Directory "$name-nunit.xml") @@ -84,44 +128,76 @@ function Assert-CiGateTestEvidence { } function Assert-CiGateReportSummary { - param([Parameter(Mandatory)]$Actual, [Parameter(Mandatory)]$Expected) + param( + [Parameter(Mandatory)]$Actual, + [Parameter(Mandatory)]$Expected + ) foreach ($field in 'Total', 'Passed', 'Failed', 'Skipped') { - if ($Actual.$field -ne $Expected.$field) { throw "Manifest '$field' differs from actual test evidence." } + if ($Actual.$field -ne $Expected.$field) { + throw "Manifest '$field' differs from actual test evidence." + } + } + if ($Actual.Passed -le 0 -or $Actual.Failed -ne 0) { + throw 'Tests did not execute successfully.' } - if ($Actual.Passed -le 0 -or $Actual.Failed -ne 0) { throw 'Tests did not execute successfully.' } } function Assert-CiSerializedDetail { - param([Parameter(Mandatory)][object[]]$Actual, [Parameter(Mandatory)][object[]]$Serialized) + param( + [Parameter(Mandatory)][object[]]$Actual, + [Parameter(Mandatory)][object[]]$Serialized + ) - if ($Actual.Count -ne $Serialized.Count) { throw 'Serialized test detail count differs from retained log.' } + if ($Actual.Count -ne $Serialized.Count) { + throw 'Serialized test detail count differs from retained log.' + } for ($index = 0; $index -lt $Actual.Count; $index++) { foreach ($name in 'File', 'Name', 'Result') { - if ($Actual[$index].$name -cne $Serialized[$index].$name) { throw 'Serialized test detail differs from retained log.' } + if ($Actual[$index].$name -cne $Serialized[$index].$name) { + throw 'Serialized test detail differs from retained log.' + } } } } function Assert-CiGateManifestIdentity { - param([Parameter(Mandatory)]$Manifest, [Parameter(Mandatory)]$RunContext) + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)]$RunContext + ) foreach ($name in 'runId', 'runAttempt') { - if ($Manifest.identity.$name -cne $RunContext.$name) { throw "Manifest belongs to another '$name'." } + if ($Manifest.identity.$name -cne $RunContext.$name) { + throw "Manifest belongs to another '$name'." + } + } + if ($Manifest.identity.commitSha -cne $Manifest.commitSha -or $Manifest.identity.laneId -cne $Manifest.laneId) { + throw 'Manifest and execution identity differ.' + } + if ($Manifest.identity.executionId -notmatch '^[a-f0-9]{32}$') { + throw 'Missing unique lane execution identity.' } - if ($Manifest.identity.commitSha -cne $Manifest.commitSha -or $Manifest.identity.laneId -cne $Manifest.laneId) { throw 'Manifest and execution identity differ.' } - if ($Manifest.identity.executionId -notmatch '^[a-f0-9]{32}$') { throw 'Missing unique lane execution identity.' } } function Assert-CiGateManifestEvidence { - param([Parameter(Mandatory)]$Manifest, [Parameter(Mandatory)]$Expected, [Parameter(Mandatory)]$RunContext, [Parameter(Mandatory)][string]$Directory) + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)]$Expected, + [Parameter(Mandatory)]$RunContext, + [Parameter(Mandatory)][string]$Directory + ) Assert-CiGateManifestSchema -Manifest $Manifest - if ($Manifest.schemaVersion -ne 1) { throw 'Unsupported or missing manifest schema.' } + if ($Manifest.schemaVersion -ne 1) { + throw 'Unsupported or missing manifest schema.' + } Assert-CiGateManifestIdentity -Manifest $Manifest -RunContext $RunContext Assert-CiGateConfiguration -Manifest $Manifest -Expected $Expected Assert-CiGateProvisioning -Manifest $Manifest Assert-CiLaneRuntimeEvidence -Directory (Join-Path $Directory 'logs') -Phase $Manifest.phases -Expected $Manifest.selectedRuntime -Identity $Manifest.identity Assert-CiGateTestEvidence -Manifest $Manifest -Directory $Directory - if ((Get-CiLaneStatus -Mismatch $Manifest.mismatches -Phase $Manifest.phases -Summary $Manifest.tests) -ne 'passed') { throw 'Manifest evidence does not establish a passed lane.' } + if ((Get-CiLaneStatus -Mismatch $Manifest.mismatches -Phase $Manifest.phases -Summary $Manifest.tests) -ne 'passed') { + throw 'Manifest evidence does not establish a passed lane.' + } } diff --git a/scripts/build/ci/CiGateSchema.ps1 b/scripts/build/ci/CiGateSchema.ps1 index 9deb0f1..1ba4632 100644 --- a/scripts/build/ci/CiGateSchema.ps1 +++ b/scripts/build/ci/CiGateSchema.ps1 @@ -1,17 +1,28 @@ function Assert-CiRequiredField { - param([Parameter(Mandatory)]$Object, [Parameter(Mandatory)][string[]]$Name) + param( + [Parameter(Mandatory)]$Object, + [Parameter(Mandatory)][string[]]$Name + ) - if ($Object -isnot [System.Collections.IDictionary]) { throw 'Expected a JSON object.' } + if ($Object -isnot [System.Collections.IDictionary]) { + throw 'Expected a JSON object.' + } foreach ($field in $Name) { - if (-not $Object.Contains($field)) { throw "Required manifest field '$field' is missing." } + if (-not $Object.Contains($field)) { + throw "Required manifest field '$field' is missing." + } } } function Assert-CiNonnegativeInteger { param([AllowNull()]$Value) - if ($Value -isnot [int] -and $Value -isnot [long]) { throw 'Expected an integer evidence value.' } - if ($Value -lt 0) { throw 'Expected a nonnegative evidence value.' } + if ($Value -isnot [int] -and $Value -isnot [long]) { + throw 'Expected an integer evidence value.' + } + if ($Value -lt 0) { + throw 'Expected a nonnegative evidence value.' + } } function Assert-CiGateSummarySchema { @@ -21,7 +32,9 @@ function Assert-CiGateSummarySchema { foreach ($name in 'Total', 'Passed', 'Failed', 'Skipped') { Assert-CiNonnegativeInteger -Value $Summary[$name] } - if (@($Summary.SkippedName).Count -ne $Summary.Skipped) { throw 'Manifest is missing skipped-test identities.' } + if (@($Summary.SkippedName).Count -ne $Summary.Skipped) { + throw 'Manifest is missing skipped-test identities.' + } } function Assert-CiGatePhaseSchema { @@ -31,11 +44,21 @@ function Assert-CiGatePhaseSchema { Assert-CiNonnegativeInteger -Value $Phase.ExitCode Assert-CiNonnegativeInteger -Value $Phase.ProcessId $number = $Phase.DurationSeconds - if ($number.GetType().Name -notin 'Int32', 'Int64', 'Double', 'Decimal') { throw 'Invalid phase duration.' } - if (-not [double]::IsFinite($number) -or $number -lt 0) { throw 'Invalid phase duration.' } - if ($Phase.LogFile -cne "$($Phase.Name).log") { throw 'Unexpected phase log file.' } - if ($Phase.RuntimeFile -cne "$($Phase.Name).log.runtime.json") { throw 'Unexpected phase runtime file.' } - if ($Phase.TestRuntimeFile -cne "$($Phase.Name).log.test-runtime.json") { throw 'Unexpected test runtime file.' } + if ($number.GetType().Name -notin 'Int32', 'Int64', 'Double', 'Decimal') { + throw 'Invalid phase duration.' + } + if (-not [double]::IsFinite($number) -or $number -lt 0) { + throw 'Invalid phase duration.' + } + if ($Phase.LogFile -cne "$($Phase.Name).log") { + throw 'Unexpected phase log file.' + } + if ($Phase.RuntimeFile -cne "$($Phase.Name).log.runtime.json") { + throw 'Unexpected phase runtime file.' + } + if ($Phase.TestRuntimeFile -cne "$($Phase.Name).log.test-runtime.json") { + throw 'Unexpected test runtime file.' + } } function Assert-CiGateModuleSchema { @@ -44,10 +67,16 @@ function Assert-CiGateModuleSchema { $required = @('Pester', 'NovaModuleTools', 'PSScriptAnalyzer', 'Microsoft.PowerShell.PSResourceGet', 'Microsoft.PowerShell.PlatyPS') Assert-CiRequiredField -Object $Module -Name $required foreach ($name in $required) { - if (@($Module[$name]).Count -ne 1) { throw "Missing selected module availability '$name'." } - if ($Module[$name][0] -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { throw "Invalid module version '$name'." } + if (@($Module[$name]).Count -ne 1) { + throw "Missing selected module availability '$name'." + } + if ($Module[$name][0] -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { + throw "Invalid module version '$name'." + } + } + if ($Module['Microsoft.PowerShell.PSResourceGet'][0] -cne '1.2.0') { + throw 'PSResourceGet 1.2.0 availability is required.' } - if ($Module['Microsoft.PowerShell.PSResourceGet'][0] -cne '1.2.0') { throw 'PSResourceGet 1.2.0 availability is required.' } } function Assert-CiGateManifestSchema { @@ -57,6 +86,10 @@ function Assert-CiGateManifestSchema { Assert-CiNonnegativeInteger -Value $Manifest.schemaVersion Assert-CiRequiredField -Object $Manifest.identity -Name @('laneId', 'commitSha', 'runId', 'runAttempt', 'executionId') Assert-CiGateModuleSchema -Module $Manifest.modules - foreach ($phase in $Manifest.phases) { Assert-CiGatePhaseSchema -Phase $phase } - foreach ($name in 'unit', 'integration') { Assert-CiGateSummarySchema -Summary $Manifest.tests[$name] } + foreach ($phase in $Manifest.phases) { + Assert-CiGatePhaseSchema -Phase $phase + } + foreach ($name in 'unit', 'integration') { + Assert-CiGateSummarySchema -Summary $Manifest.tests[$name] + } } diff --git a/scripts/build/ci/CiLaneManifest.ps1 b/scripts/build/ci/CiLaneManifest.ps1 index db5abb7..41ad47d 100644 --- a/scripts/build/ci/CiLaneManifest.ps1 +++ b/scripts/build/ci/CiLaneManifest.ps1 @@ -5,7 +5,7 @@ function Test-CiPhaseSucceeded { [Parameter(Mandatory)][string]$Name ) - $match = @($Phase | Where-Object {$_.Name -eq $Name}) + $match = @($Phase | Where-Object { $_.Name -eq $Name }) return $match.Count -eq 1 -and $match[0].ExitCode -eq 0 } @@ -34,7 +34,7 @@ function Get-CiLaneStatus { [Parameter(Mandatory)][hashtable]$Summary ) - $phaseHealthy = @('build', 'unit', 'integration' | Where-Object {-not (Test-CiPhaseSucceeded -Phase $Phase -Name $_)}).Count -eq 0 + $phaseHealthy = @('build', 'unit', 'integration' | Where-Object { -not (Test-CiPhaseSucceeded -Phase $Phase -Name $_) }).Count -eq 0 $summaryHealthy = (Test-CiSummaryHealthy -Summary $Summary -Key 'unit') -and (Test-CiSummaryHealthy -Summary $Summary -Key 'integration') $check = @( @($Mismatch).Count -eq 0 diff --git a/scripts/build/ci/CiRuntimeEvidence.ps1 b/scripts/build/ci/CiRuntimeEvidence.ps1 index 3d2f15d..cf570f8 100644 --- a/scripts/build/ci/CiRuntimeEvidence.ps1 +++ b/scripts/build/ci/CiRuntimeEvidence.ps1 @@ -9,57 +9,102 @@ function Get-CiRuntimeRecord { } function Assert-CiRuntimeRecord { - param([Parameter(Mandatory)]$Record, [Parameter(Mandatory)]$Expected) + param( + [Parameter(Mandatory)]$Record, + [Parameter(Mandatory)]$Expected + ) $mismatch = @(Test-CiLaneEnvironment -Expected $Expected -Actual $Record.Environment) - if ($Record.Executable -ne $Expected.Executable) { $mismatch += 'PowerShell executable differs from the selected absolute path.' } - if ($mismatch.Count -gt 0) { throw ($mismatch -join ' ') } + if ($Record.Executable -ne $Expected.Executable) { + $mismatch += 'PowerShell executable differs from the selected absolute path.' + } + if ($mismatch.Count -gt 0) { + throw ($mismatch -join ' ') + } } function Read-CiRuntimeProbe { param([Parameter(Mandatory)][string]$Executable) - if (-not [IO.Path]::IsPathRooted($Executable)) { throw 'PowerShell executable must be an absolute path.' } + if (-not [IO.Path]::IsPathRooted($Executable)) { + throw 'PowerShell executable must be an absolute path.' + } $output = & $Executable -NoLogo -NoProfile -File $PSCommandPath - if ($LASTEXITCODE -ne 0) { throw 'PowerShell runtime probe failed.' } + if ($LASTEXITCODE -ne 0) { + throw 'PowerShell runtime probe failed.' + } return $output | ConvertFrom-Json } function Assert-CiRuntimeIdentity { - param([Parameter(Mandatory)]$Record, [Parameter(Mandatory)]$Identity, [Parameter(Mandatory)][string]$Phase) + param( + [Parameter(Mandatory)]$Record, + [Parameter(Mandatory)]$Identity, + [Parameter(Mandatory)][string]$Phase + ) foreach ($name in 'laneId', 'commitSha', 'runId', 'runAttempt', 'executionId') { - if ($Record.Context.$name -cne $Identity.$name) { throw "Runtime evidence identity differs at '$name'." } + if ($Record.Context.$name -cne $Identity.$name) { + throw "Runtime evidence identity differs at '$name'." + } + } + if ($Record.Context.phase -cne $Phase) { + throw 'Runtime evidence belongs to another phase.' + } + if ($Record.ProcessId -le 0) { + throw 'Runtime evidence has no actual process identity.' } - if ($Record.Context.phase -cne $Phase) { throw 'Runtime evidence belongs to another phase.' } - if ($Record.ProcessId -le 0) { throw 'Runtime evidence has no actual process identity.' } } function Assert-CiPhaseRuntimeEvidence { - param([Parameter(Mandatory)]$Phase, [Parameter(Mandatory)]$Expected, [Parameter(Mandatory)]$Identity, [Parameter(Mandatory)][string]$Directory) + param( + [Parameter(Mandatory)]$Phase, + [Parameter(Mandatory)]$Expected, + [Parameter(Mandatory)]$Identity, + [Parameter(Mandatory)][string]$Directory + ) Assert-CiRuntimeRecord -Record $Phase -Expected $Expected $path = Join-Path $Directory "$($Phase.Name).log.runtime.json" $record = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json Assert-CiRuntimeRecord -Record $record -Expected $Expected Assert-CiRuntimeIdentity -Record $record -Identity $Identity -Phase $Phase.Name - if ($record.ProcessId -ne $Phase.ProcessId) { throw 'Phase runtime process identity differs.' } - if ($Phase.Name -eq 'build') { return } + if ($record.ProcessId -ne $Phase.ProcessId) { + throw 'Phase runtime process identity differs.' + } + if ($Phase.Name -eq 'build') { + return + } $witness = Get-Content -LiteralPath (Join-Path $Directory "$($Phase.Name).log.test-runtime.json") -Raw -ErrorAction Stop | ConvertFrom-Json Assert-CiRuntimeRecord -Record $witness -Expected $Expected Assert-CiRuntimeIdentity -Record $witness -Identity $Identity -Phase $Phase.Name - if ($witness.ProcessId -ne $record.ProcessId) { throw 'Nova test runtime is not the validated phase runtime.' } - if ($witness.PesterVersion -cne '5.7.1') { throw 'Nova tests did not execute with the pinned Pester version.' } + if ($witness.ProcessId -ne $record.ProcessId) { + throw 'Nova test runtime is not the validated phase runtime.' + } + if ($witness.PesterVersion -cne '5.7.1') { + throw 'Nova tests did not execute with the pinned Pester version.' + } } function Assert-CiLaneRuntimeEvidence { - param([Parameter(Mandatory)][string]$Directory, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Phase, [Parameter(Mandatory)]$Expected, [Parameter(Mandatory)]$Identity) + param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Phase, + [Parameter(Mandatory)]$Expected, + [Parameter(Mandatory)]$Identity + ) - if ($Phase.Count -ne 3) { throw 'Exactly three completed phase records are required.' } + if ($Phase.Count -ne 3) { + throw 'Exactly three completed phase records are required.' + } foreach ($name in 'build', 'unit', 'integration') { $own = @($Phase | Where-Object Name -eq $name) - if ($own.Count -ne 1) { throw "Missing or duplicate phase '$name'." } - if ($own[0].ExitCode -ne 0) { throw "Phase '$name' failed." } + if ($own.Count -ne 1) { + throw "Missing or duplicate phase '$name'." + } + if ($own[0].ExitCode -ne 0) { + throw "Phase '$name' failed." + } Assert-CiPhaseRuntimeEvidence -Phase $own[0] -Expected $Expected -Identity $Identity -Directory $Directory } } diff --git a/scripts/build/ci/CiTestDetail.ps1 b/scripts/build/ci/CiTestDetail.ps1 index 84e3130..fff665d 100644 --- a/scripts/build/ci/CiTestDetail.ps1 +++ b/scripts/build/ci/CiTestDetail.ps1 @@ -12,12 +12,28 @@ function Read-CiTestDetail { } function ConvertFrom-CiTestDetailLine { - param([string]$Line, [AllowNull()][string]$File) + param( + [string]$Line, + [AllowNull()][string]$File + ) - if ($Line -notmatch '^\s+\[([+!\-?])\] (.+) \d+(?:\.\d+)?(?:ms|s) \(') { return } - if (-not $File) { throw 'Test detail has no owning file.' } - $result = @{ '+' = 'Passed'; '!' = 'Skipped'; '-' = 'Failed'; '?' = 'Inconclusive' }[$Matches[1]] - return [pscustomobject]@{ File = $File; Name = $Matches[2]; Result = $result } + if ($Line -notmatch '^\s+\[([+!\-?])\] (.+) \d+(?:\.\d+)?(?:ms|s) \(') { + return + } + if (-not $File) { + throw 'Test detail has no owning file.' + } + $result = @{ + '+' = 'Passed' + '!' = 'Skipped' + '-' = 'Failed' + '?' = 'Inconclusive' + }[$Matches[1]] + return [pscustomobject]@{ + File = $File + Name = $Matches[2] + Result = $result + } } function Get-CiAllowedSkip { @@ -28,23 +44,38 @@ function Get-CiAllowedSkip { } function Assert-CiAllowedSkip { - param([Parameter(Mandatory)][object[]]$Detail, [Parameter(Mandatory)][string]$OperatingSystem) + param( + [Parameter(Mandatory)][object[]]$Detail, + [Parameter(Mandatory)][string]$OperatingSystem + ) $policy = @(Get-CiAllowedSkip -OperatingSystem $OperatingSystem) foreach ($test in $Detail | Where-Object Result -eq 'Skipped') { $allowed = @($policy | Where-Object { $_.file -ceq $test.File -and $_.name -ceq $test.Name }) - if ($allowed.Count -ne 1) { throw "Unexpected skip '$($test.File): $($test.Name)' on $OperatingSystem." } + if ($allowed.Count -ne 1) { + throw "Unexpected skip '$($test.File): $($test.Name)' on $OperatingSystem." + } } } function Assert-CiTestDetail { - param([Parameter(Mandatory)]$Summary, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Detail, [Parameter(Mandatory)][string]$OperatingSystem) + param( + [Parameter(Mandatory)]$Summary, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Detail, + [Parameter(Mandatory)][string]$OperatingSystem + ) - if ($Detail.Count -ne $Summary.Total) { throw 'Incomplete detailed test evidence: total differs from NUnit.' } + if ($Detail.Count -ne $Summary.Total) { + throw 'Incomplete detailed test evidence: total differs from NUnit.' + } foreach ($name in 'Passed', 'Skipped') { - if (@($Detail | Where-Object Result -eq $name).Count -ne $Summary.$name) { throw "Detailed '$name' count differs from NUnit." } + if (@($Detail | Where-Object Result -eq $name).Count -ne $Summary.$name) { + throw "Detailed '$name' count differs from NUnit." + } } $failed = @($Detail | Where-Object { $_.Result -in 'Failed', 'Inconclusive' }).Count - if ($failed -ne $Summary.Failed) { throw 'Detailed failure count differs from NUnit.' } + if ($failed -ne $Summary.Failed) { + throw 'Detailed failure count differs from NUnit.' + } Assert-CiAllowedSkip -Detail $Detail -OperatingSystem $OperatingSystem } diff --git a/scripts/build/ci/CiTestResultSummary.ps1 b/scripts/build/ci/CiTestResultSummary.ps1 index d7caece..6f71de0 100644 --- a/scripts/build/ci/CiTestResultSummary.ps1 +++ b/scripts/build/ci/CiTestResultSummary.ps1 @@ -1,10 +1,17 @@ function Get-CiNUnitCount { - param([Parameter(Mandatory)]$Node, [Parameter(Mandatory)][string]$Name) + param( + [Parameter(Mandatory)]$Node, + [Parameter(Mandatory)][string]$Name + ) $value = $Node.GetAttribute($Name) - if ($value -notmatch '^\d+$') { throw "Invalid or missing NUnit count '$Name'." } + if ($value -notmatch '^\d+$') { + throw "Invalid or missing NUnit count '$Name'." + } $number = 0 - if (-not [int]::TryParse($value, [ref]$number)) { throw "NUnit count '$Name' is out of range." } + if (-not [int]::TryParse($value, [ref]$number)) { + throw "NUnit count '$Name' is out of range." + } return $number } @@ -12,9 +19,13 @@ function Get-CiAggregateCount { param([Parameter(Mandatory)][xml]$Document) $root = $Document.DocumentElement - if ($root.LocalName -ne 'test-results') { throw 'Expected an NUnit test-results document.' } + if ($root.LocalName -ne 'test-results') { + throw 'Expected an NUnit test-results document.' + } $suite = @($root.SelectNodes('./test-suite')) - if ($suite.Count -ne 1) { throw 'Expected one NUnit root test-suite.' } + if ($suite.Count -ne 1) { + throw 'Expected one NUnit root test-suite.' + } $count = @{} foreach ($name in 'total', 'errors', 'failures', 'inconclusive', 'skipped') { $count[$name] = Get-CiNUnitCount -Node $root -Name $name @@ -25,16 +36,29 @@ function Get-CiAggregateCount { } function Assert-CiNUnitCount { - param([Parameter(Mandatory)][hashtable]$Count, [Parameter(Mandatory)]$Suite) + param( + [Parameter(Mandatory)][hashtable]$Count, + [Parameter(Mandatory)]$Suite + ) $failed = $Count.errors + $Count.failures - if ($Count.total -le 0) { throw 'NUnit report contains no tests.' } - if ($Count.total -ne ($Count.passed + $failed + $Count.inconclusive + $Count.skipped)) { throw 'Inconsistent NUnit totals.' } + if ($Count.total -le 0) { + throw 'NUnit report contains no tests.' + } + if ($Count.total -ne ($Count.passed + $failed + $Count.inconclusive + $Count.skipped)) { + throw 'Inconsistent NUnit totals.' + } foreach ($name in 'total', 'skipped') { - if ((Get-CiNUnitCount -Node $Suite -Name $name) -ne $Count[$name]) { throw "Inconsistent NUnit suite '$name'." } + if ((Get-CiNUnitCount -Node $Suite -Name $name) -ne $Count[$name]) { + throw "Inconsistent NUnit suite '$name'." + } + } + if ((Get-CiNUnitCount -Node $Suite -Name 'failed') -ne $failed) { + throw 'Inconsistent NUnit failure count.' + } + if ($Suite.HasAttribute('inconclusive') -and (Get-CiNUnitCount -Node $Suite -Name 'inconclusive') -ne $Count.inconclusive) { + throw 'Inconsistent NUnit inconclusive count.' } - if ((Get-CiNUnitCount -Node $Suite -Name 'failed') -ne $failed) { throw 'Inconsistent NUnit failure count.' } - if ($Suite.HasAttribute('inconclusive') -and (Get-CiNUnitCount -Node $Suite -Name 'inconclusive') -ne $Count.inconclusive) { throw 'Inconsistent NUnit inconclusive count.' } } function Get-CiTestCaseCount { @@ -52,7 +76,9 @@ function Get-CiTestCaseCount { function Get-CiTestResultSummary { param([Parameter(Mandatory)][string]$Path) - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Test result file '$Path' was not found." } + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Test result file '$Path' was not found." + } [xml]$document = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop $count = Get-CiAggregateCount -Document $document $summary = [ordered]@{ @@ -68,13 +94,20 @@ function Get-CiTestResultSummary { } function Get-CiDetailedSkippedName { - param([Parameter(Mandatory)][xml]$Document, [Parameter(Mandatory)]$Summary) + param( + [Parameter(Mandatory)][xml]$Document, + [Parameter(Mandatory)]$Summary + ) $cases = @($Document.SelectNodes('//test-case')) - if ($cases.Count -eq 0) { return } + if ($cases.Count -eq 0) { + return + } $count = Get-CiTestCaseCount -Case $cases foreach ($name in 'Total', 'Passed', 'Skipped') { - if ($count[$name] -ne $Summary[$name]) { throw "NUnit case detail disagrees with '$name'." } + if ($count[$name] -ne $Summary[$name]) { + throw "NUnit case detail disagrees with '$name'." + } } return $count.SkippedName } diff --git a/scripts/build/ci/Get-CiPlatformMatrix.ps1 b/scripts/build/ci/Get-CiPlatformMatrix.ps1 index 2ad4440..3656311 100644 --- a/scripts/build/ci/Get-CiPlatformMatrix.ps1 +++ b/scripts/build/ci/Get-CiPlatformMatrix.ps1 @@ -35,7 +35,7 @@ function Get-CiPlatformMatrix { $lane = @((Get-Content -LiteralPath $LanePath -Raw | ConvertFrom-Json).lanes) Assert-CiLaneDefinition -Lane $lane - return @{include = $lane} | ConvertTo-Json -Depth 4 -Compress + return @{include = $lane } | ConvertTo-Json -Depth 4 -Compress } if ($MyInvocation.InvocationName -ne '.') { diff --git a/scripts/build/ci/Install-CiPowerShell.ps1 b/scripts/build/ci/Install-CiPowerShell.ps1 index 8bd9776..f9302b0 100644 --- a/scripts/build/ci/Install-CiPowerShell.ps1 +++ b/scripts/build/ci/Install-CiPowerShell.ps1 @@ -19,10 +19,18 @@ function Get-CiPowerShellAssetName { ) switch ($RuntimeIdentifier) { - 'win-x64' {return "PowerShell-$Version-win-x64.zip"} - 'linux-x64' {return "powershell-$Version-linux-x64.tar.gz"} - 'osx-arm64' {return "powershell-$Version-osx-arm64.tar.gz"} - default {throw "Unsupported runtime identifier '$RuntimeIdentifier'."} + 'win-x64' { + return "PowerShell-$Version-win-x64.zip" + } + 'linux-x64' { + return "powershell-$Version-linux-x64.tar.gz" + } + 'osx-arm64' { + return "powershell-$Version-osx-arm64.tar.gz" + } + default { + throw "Unsupported runtime identifier '$RuntimeIdentifier'." + } } } @@ -170,7 +178,11 @@ function Assert-CiInstalledPowerShell { [Parameter(Mandatory)]$Installation ) - $name = if ($Installation.runtimeIdentifier -like 'win-*') {'pwsh.exe'} else {'pwsh'} + $name = if ($Installation.runtimeIdentifier -like 'win-*') { + 'pwsh.exe' + } else { + 'pwsh' + } $record = Read-CiRuntimeProbe -Executable (Join-Path $Installation.directory $name) $runtime = @{ 'win-x64' = @('Windows', 'X64') @@ -178,8 +190,16 @@ function Assert-CiInstalledPowerShell { 'osx-arm64' = @('macOS', 'Arm64') } $platform = $runtime[$Installation.runtimeIdentifier] - if (-not $platform) { throw 'Unsupported provisioning runtime.' } - $expected = @{ OperatingSystem = $platform[0]; Architecture = $platform[1]; PowerShellVersion = $Installation.version; PowerShellChannel = $Installation.channel; Executable = $record.Executable } + if (-not $platform) { + throw 'Unsupported provisioning runtime.' + } + $expected = @{ + OperatingSystem = $platform[0] + Architecture = $platform[1] + PowerShellVersion = $Installation.version + PowerShellChannel = $Installation.channel + Executable = $record.Executable + } Assert-CiRuntimeRecord -Record $record -Expected $expected $Installation | Add-Member -NotePropertyName executable -NotePropertyValue $record.Executable -Force $Installation | Add-Member -NotePropertyName runtime -NotePropertyValue $record -Force diff --git a/scripts/build/ci/Install-CiPowerShellModules.ps1 b/scripts/build/ci/Install-CiPowerShellModules.ps1 index a0b5664..981fc0d 100644 --- a/scripts/build/ci/Install-CiPowerShellModules.ps1 +++ b/scripts/build/ci/Install-CiPowerShellModules.ps1 @@ -7,9 +7,10 @@ param( . (Join-Path $PSScriptRoot 'CiDependencyRoot.ps1') $script:PinnedVersion = @{ - Pester = '5.7.1' + Pester = '5.7.1' 'Microsoft.PowerShell.PSResourceGet' = '1.2.0' } +$script:DownloadAttemptLimit = 5 function Get-CiModuleInstallParameter { [CmdletBinding()] @@ -18,13 +19,7 @@ function Get-CiModuleInstallParameter { [string]$ModuleRoot ) - $parameter = @{ - Name = $Name - Repository = 'PSGallery' - Path = $ModuleRoot - Force = $true - ErrorAction = 'Stop' - } + $parameter = Get-CiModuleSaveParameter -Name $Name -ModuleRoot $ModuleRoot if ($script:PinnedVersion.ContainsKey($Name)) { $parameter['RequiredVersion'] = $script:PinnedVersion[$Name] } @@ -32,25 +27,102 @@ function Get-CiModuleInstallParameter { return $parameter } +function Get-CiModuleSaveParameter { + param([string]$Name, [string]$ModuleRoot) + + return @{ + Name = $Name + Repository = 'PSGallery' + Path = $ModuleRoot + Force = $true + ErrorAction = 'Stop' + } +} + function Save-CiPowerShellModule { - param([string[]]$Name, [Parameter(Mandatory)][string]$ModuleRoot) + param( + [string[]]$Name, + [Parameter(Mandatory)][string]$ModuleRoot + ) $owner = New-CiDependencyRoot -Path $ModuleRoot try { - foreach ($moduleName in $Name) { - $parameter = Get-CiModuleInstallParameter -Name $moduleName -ModuleRoot $owner.Path - Save-Module @parameter - } - & (Get-Process -Id $PID).Path -NoLogo -NoProfile -File (Join-Path $PSScriptRoot 'CiDependencyProbe.ps1') -ModuleRoot $owner.Path - if ($LASTEXITCODE -ne 0) { throw 'Isolated dependency closure validation failed.' } + Save-CiModuleSet -Name $Name -ModuleRoot $owner.Path return $owner - } - catch { + } catch { Remove-CiDependencyRoot -Owner $owner throw } } +function Save-CiModuleSet { + param([string[]]$Name, [string]$ModuleRoot) + $registered = Open-CiGallery + try { + $null = Assert-CiGallery + foreach ($moduleName in $Name) { + Save-CiModuleDownload (Get-CiModuleInstallParameter -Name $moduleName -ModuleRoot $ModuleRoot) + } + Assert-CiDependencyClosure -ModuleRoot $ModuleRoot + } finally { + Close-CiGallery -Registered $registered + } +} + +function Open-CiGallery { + param() + $repository = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue + if ($repository) { return $false } + Register-CiGallery + return $true +} + +function Register-CiGallery { + param() + Register-PSRepository -Default -ErrorAction Stop +} + +function Assert-CiGallery { + param() + $repository = Get-PSRepository -Name PSGallery -ErrorAction Stop + if ($repository.SourceLocation.TrimEnd('/') -cne 'https://www.powershellgallery.com/api/v2') { + throw 'CI dependencies require the official PSGallery repository.' + } + return $repository +} + +function Close-CiGallery { + param([bool]$Registered) + if (-not $Registered) { return } + $repository = Assert-CiGallery + if ($repository.InstallationPolicy -cne 'Untrusted') { + throw 'The temporary PSGallery registration changed; refusing to remove it.' + } + Unregister-PSRepository -Name PSGallery -ErrorAction Stop +} + +function Save-CiModuleDownload { + param([hashtable]$Parameter) + foreach ($attempt in 1..$script:DownloadAttemptLimit) { + try { + $null = Save-Module @Parameter + return + } catch { + if ($attempt -eq $script:DownloadAttemptLimit) { throw } + Write-Warning "CI download for '$($Parameter.Name)' failed (attempt $attempt/$script:DownloadAttemptLimit); retrying the same Gallery request." + Start-Sleep -Seconds $attempt + } + } +} + +function Assert-CiDependencyClosure { + param([string]$ModuleRoot) + & (Get-Process -Id $PID).Path -NoLogo -NoProfile -File (Join-Path $PSScriptRoot 'CiDependencyProbe.ps1') -ModuleRoot $ModuleRoot + if ($LASTEXITCODE -ne 0) { + throw 'Isolated dependency closure validation failed.' + } +} + if ($MyInvocation.InvocationName -ne '.') { $owner = Save-CiPowerShellModule -Name $Name -ModuleRoot $ModuleRoot if ($env:GITHUB_OUTPUT) { diff --git a/scripts/build/ci/Invoke-CodeSceneAnalysis.ps1 b/scripts/build/ci/Invoke-CodeSceneAnalysis.ps1 index d2f681b..1c1cba1 100644 --- a/scripts/build/ci/Invoke-CodeSceneAnalysis.ps1 +++ b/scripts/build/ci/Invoke-CodeSceneAnalysis.ps1 @@ -140,8 +140,7 @@ if ($shouldUploadCoverage) { if ($LASTEXITCODE -ne 0) { throw "CodeScene branch-coverage upload failed with exit code $LASTEXITCODE." } - } - else { + } else { Write-Host "Skipping branch-coverage upload: '$resolvedCoveragePath' does not contain entries." } } diff --git a/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 b/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 index 4dcd058..b06049d 100644 --- a/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 +++ b/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 @@ -73,17 +73,14 @@ try { if (@($ExcludeTag).Count -gt 0) { Invoke-NovaTest -ExcludeTagFilter $ExcludeTag Test-NovaBuild -ExcludeTagFilter $ExcludeTag - } - else { + } else { Invoke-NovaTest Test-NovaBuild } -} -catch { +} catch { $testFailed = $true Write-Warning "CI tests failed: $( $_.Exception.Message )" -} -finally { +} finally { Copy-CiArtifactsIfPresent -ProjectRoot $projectInfo.ProjectRoot -ArtifactsDirectory $OutputDirectory -ProjectName $projectInfo.ProjectName } diff --git a/scripts/build/ci/Invoke-PlatformLane.ps1 b/scripts/build/ci/Invoke-PlatformLane.ps1 index 7c7fde7..07c203c 100644 --- a/scripts/build/ci/Invoke-PlatformLane.ps1 +++ b/scripts/build/ci/Invoke-PlatformLane.ps1 @@ -21,7 +21,7 @@ function Get-CiLaneDefinition { ) $lane = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'CiLanes.json') -Raw | ConvertFrom-Json).lanes | - Where-Object {$_.id -eq $LaneId} + Where-Object { $_.id -eq $LaneId } if (-not $lane) { throw "Unknown lane '$LaneId'." } @@ -46,9 +46,11 @@ function Invoke-CiLanePhase { $contextFile = "$LogPath.context.json" $launch | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $contextFile & $launch.Expected.Executable -NoLogo -NoProfile -File (Join-Path $PSScriptRoot 'Invoke-CiPhaseProcess.ps1') -ContextFile $contextFile 2>&1 | - Tee-Object -FilePath $LogPath | Out-Host + Tee-Object -FilePath $LogPath | Out-Host $exitCode = $LASTEXITCODE - $runtime = if (Test-Path -LiteralPath $launch.RuntimePath) { Get-Content -LiteralPath $launch.RuntimePath -Raw | ConvertFrom-Json } + $runtime = if (Test-Path -LiteralPath $launch.RuntimePath) { + Get-Content -LiteralPath $launch.RuntimePath -Raw | ConvertFrom-Json + } return [pscustomobject][ordered]@{ Name = $Name @@ -64,15 +66,33 @@ function Invoke-CiLanePhase { } function Get-CiPhaseContext { - param([string]$Name, [string]$LogPath, $Context) + param( + [string]$Name, + [string]$LogPath, + $Context + ) if (-not $Context) { $actual = Get-CiRuntimeRecord - $Context = @{ Expected = @{ OperatingSystem = $actual.Environment.OperatingSystem; Architecture = $actual.Environment.ProcessArchitecture; PowerShellVersion = $actual.Environment.PowerShellVersion; PowerShellChannel = 'stable'; Executable = $actual.Executable }; Identity = @{} } + $Context = @{ + Expected = @{ + OperatingSystem = $actual.Environment.OperatingSystem + Architecture = $actual.Environment.ProcessArchitecture + PowerShellVersion = $actual.Environment.PowerShellVersion + PowerShellChannel = 'stable' + Executable = $actual.Executable + } + Identity = @{} + } } $identity = @{} + $Context.Identity $identity.phase = $Name - return @{ Expected = $Context.Expected; Identity = $identity; RuntimePath = "$LogPath.runtime.json"; TestRuntimePath = "$LogPath.test-runtime.json" } + return @{ + Expected = $Context.Expected + Identity = $identity + RuntimePath = "$LogPath.runtime.json" + TestRuntimePath = "$LogPath.test-runtime.json" + } } function Get-CiModuleVersion { @@ -82,7 +102,7 @@ function Get-CiModuleVersion { $module = @{} foreach ($name in 'Pester', 'NovaModuleTools', 'PSScriptAnalyzer', 'Microsoft.PowerShell.PSResourceGet', 'Microsoft.PowerShell.PlatyPS') { $module[$name] = @(Get-Module -ListAvailable -Name $name | Sort-Object Version -Descending | - Select-Object -First 1 | ForEach-Object {$_.Version.ToString()}) + Select-Object -First 1 | ForEach-Object { $_.Version.ToString() }) } return $module @@ -93,9 +113,18 @@ function Get-CiLanePhaseDefinition { param() return @( - @{Name = 'build'; Command = 'Import-Module Pester -RequiredVersion 5.7.1; Import-Module NovaModuleTools -ErrorAction Stop; Invoke-NovaBuild'} - @{Name = 'unit'; Command = 'Import-Module Pester -RequiredVersion 5.7.1; Import-Module NovaModuleTools -ErrorAction Stop; Invoke-NovaTest -OutputVerbosity Detailed -OutputRenderMode Ansi'} - @{Name = 'integration'; Command = 'Import-Module Pester -RequiredVersion 5.7.1; Import-Module NovaModuleTools -ErrorAction Stop; Test-NovaBuild -OutputVerbosity Detailed -OutputRenderMode Ansi'} + @{ + Name = 'build' + Command = 'Import-Module Pester -RequiredVersion 5.7.1; Import-Module NovaModuleTools -ErrorAction Stop; Invoke-NovaBuild' + } + @{ + Name = 'unit' + Command = 'Import-Module Pester -RequiredVersion 5.7.1; Import-Module NovaModuleTools -ErrorAction Stop; Invoke-NovaTest -OutputVerbosity Detailed -OutputRenderMode Ansi' + } + @{ + Name = 'integration' + Command = 'Import-Module Pester -RequiredVersion 5.7.1; Import-Module NovaModuleTools -ErrorAction Stop; Test-NovaBuild -OutputVerbosity Detailed -OutputRenderMode Ansi' + } ) } @@ -154,12 +183,12 @@ function Write-CiLaneSummary { ) $line = @("### $( $Manifest.laneId ): $( $Manifest.status )", '') - $line += $Manifest.mismatches | ForEach-Object {"- Environment mismatch: $_"} - $line += $Manifest.phases | ForEach-Object {"- $( $_.Name ): exit code $( $_.ExitCode ), $( $_.DurationSeconds )s"} + $line += $Manifest.mismatches | ForEach-Object { "- Environment mismatch: $_" } + $line += $Manifest.phases | ForEach-Object { "- $( $_.Name ): exit code $( $_.ExitCode ), $( $_.DurationSeconds )s" } foreach ($key in $Manifest.tests.Keys) { $test = $Manifest.tests[$key] $line += "- $key tests: $( $test.Passed ) passed, $( $test.Failed ) failed, $( $test.Skipped ) skipped" - $line += $test.SkippedName | ForEach-Object {" - skipped (see test source and logs for the reason): $_"} + $line += $test.SkippedName | ForEach-Object { " - skipped (see test source and logs for the reason): $_" } if ($test.Skipped -gt 0 -and @($test.SkippedName).Count -eq 0) { $line += " - skipped tests are not named in the report; see logs/*.log ([!] lines) and the test source for the reason" } @@ -173,36 +202,69 @@ function Write-CiLaneSummary { function Get-CiLaneIdentity { param([string]$LaneId) - $identity = @{ laneId = $LaneId; executionId = [guid]::NewGuid().ToString('N') } - $defaults = @{ commitSha = @('GITHUB_SHA', 'local'); runId = @('GITHUB_RUN_ID', 'local'); runAttempt = @('GITHUB_RUN_ATTEMPT', '1') } + $identity = @{ + laneId = $LaneId + executionId = [guid]::NewGuid().ToString('N') + } + $defaults = @{ + commitSha = @('GITHUB_SHA', 'local') + runId = @('GITHUB_RUN_ID', 'local') + runAttempt = @('GITHUB_RUN_ATTEMPT', '1') + } foreach ($name in $defaults.Keys) { $value = [Environment]::GetEnvironmentVariable($defaults[$name][0]) - $identity[$name] = if ($value) { $value } else { $defaults[$name][1] } + $identity[$name] = if ($value) { + $value + } else { + $defaults[$name][1] + } } return $identity } function Get-CiLaneRun { - param([string]$LaneId, [string]$Version, [string]$RepositoryRoot, [string]$ProvisioningFile) + param( + [string]$LaneId, + [string]$Version, + [string]$RepositoryRoot, + [string]$ProvisioningFile + ) $lane = Get-CiLaneDefinition -LaneId $LaneId $actual = Get-CiLaneEnvironment - if (-not $Version) { $Version = $actual.PowerShellVersion } - $expected = @{ OperatingSystem = $lane.os; Architecture = $lane.architecture; PowerShellChannel = $lane.powershell; PowerShellVersion = $Version; Executable = (Get-Process -Id $PID).Path } + if (-not $Version) { + $Version = $actual.PowerShellVersion + } + $expected = @{ + OperatingSystem = $lane.os + Architecture = $lane.architecture + PowerShellChannel = $lane.powershell + PowerShellVersion = $Version + Executable = (Get-Process -Id $PID).Path + } $output = Join-Path $RepositoryRoot "artifacts/ci/$LaneId" return @{ - Lane = $lane; Actual = $actual; Expected = $expected + Lane = $lane + Actual = $actual + Expected = $expected Identity = Get-CiLaneIdentity -LaneId $LaneId - RepositoryRoot = $RepositoryRoot; Output = $output; Logs = Join-Path $output 'logs' - ProvisioningFile = $ProvisioningFile; Provisioning = $null - Phase = @(); Summary = @(); Mismatch = @() + RepositoryRoot = $RepositoryRoot + Output = $output + Logs = Join-Path $output 'logs' + ProvisioningFile = $ProvisioningFile + Provisioning = $null + Phase = @() + Summary = @() + Mismatch = @() } } function Initialize-CiLaneArtifact { param([Parameter(Mandatory)][hashtable]$Run) - if (Test-Path -LiteralPath $Run.Output) { Remove-Item -LiteralPath $Run.Output -Recurse -Force } + if (Test-Path -LiteralPath $Run.Output) { + Remove-Item -LiteralPath $Run.Output -Recurse -Force + } New-Item -ItemType Directory -Path $Run.Logs -Force | Out-Null foreach ($report in 'UnitTestResults.xml', 'TestResults.xml') { Remove-Item -LiteralPath (Join-Path $Run.RepositoryRoot "artifacts/$report") -Force -ErrorAction SilentlyContinue @@ -213,11 +275,17 @@ function Initialize-CiLaneArtifact { function Assert-CiLaneProvisioning { param([Parameter(Mandatory)][hashtable]$Run) - if (-not $Run.ProvisioningFile) { return } + if (-not $Run.ProvisioningFile) { + return + } $Run.Provisioning = Get-Content -LiteralPath $Run.ProvisioningFile -Raw -ErrorAction Stop | ConvertFrom-Json Assert-CiRuntimeRecord -Record $Run.Provisioning.runtime -Expected $Run.Expected - if ($Run.Provisioning.runtimeIdentifier -ne $Run.Lane.runtimeIdentifier) { throw 'Provisioned runtime identifier differs from lane.' } - if ($Run.Provisioning.version -ne $Run.Expected.PowerShellVersion) { throw 'Provisioned version differs from selected runtime.' } + if ($Run.Provisioning.runtimeIdentifier -ne $Run.Lane.runtimeIdentifier) { + throw 'Provisioned runtime identifier differs from lane.' + } + if ($Run.Provisioning.version -ne $Run.Expected.PowerShellVersion) { + throw 'Provisioned version differs from selected runtime.' + } } function Invoke-CiLaneExecution { @@ -225,10 +293,15 @@ function Invoke-CiLaneExecution { $Run.Summary = @{} $Run.Mismatch = @(Test-CiLaneEnvironment -Expected $Run.Expected -Actual $Run.Actual -RequireGitHubHosted:([bool]$env:GITHUB_ACTIONS)) - if ($Run.Mismatch.Count -gt 0) { return } + if ($Run.Mismatch.Count -gt 0) { + return + } try { Assert-CiLaneProvisioning -Run $Run - $Run.Phase = Invoke-CiLanePhaseSet -RepositoryRoot $Run.RepositoryRoot -LogDirectory $Run.Logs -Context @{ Expected = $Run.Expected; Identity = $Run.Identity } + $Run.Phase = Invoke-CiLanePhaseSet -RepositoryRoot $Run.RepositoryRoot -LogDirectory $Run.Logs -Context @{ + Expected = $Run.Expected + Identity = $Run.Identity + } $Run.Summary = Copy-CiTestResult -RepositoryRoot $Run.RepositoryRoot -OutputDirectory $Run.Output Assert-CiLaneRuntimeEvidence -Directory $Run.Logs -Phase $Run.Phase -Expected $Run.Expected -Identity $Run.Identity } catch { @@ -252,7 +325,12 @@ function Write-CiLaneManifest { function Invoke-CiLane { [CmdletBinding()] - param([Parameter(Mandatory)][string]$LaneId, [string]$ExpectedPowerShellVersion, [string]$ProvisioningFile, [Parameter(Mandatory)][string]$RepositoryRoot) + param( + [Parameter(Mandatory)][string]$LaneId, + [string]$ExpectedPowerShellVersion, + [string]$ProvisioningFile, + [Parameter(Mandatory)][string]$RepositoryRoot + ) $run = Get-CiLaneRun -LaneId $LaneId -Version $ExpectedPowerShellVersion -RepositoryRoot $RepositoryRoot -ProvisioningFile $ProvisioningFile Initialize-CiLaneArtifact -Run $run @@ -273,19 +351,29 @@ if ($MyInvocation.InvocationName -ne '.') { if (-not $ModuleRoot) { $ModuleRoot = Join-Path $scratch "pmu-ci-modules-$([guid]::NewGuid().ToString('N'))" & (Get-Process -Id $PID).Path -NoLogo -NoProfile -File (Join-Path $PSScriptRoot 'Install-CiPowerShellModules.ps1') -ModuleRoot $ModuleRoot - if ($LASTEXITCODE -ne 0) { throw 'Disposable dependency provisioning failed.' } + if ($LASTEXITCODE -ne 0) { + throw 'Disposable dependency provisioning failed.' + } } $owner = Get-CiDependencyOwner -Path $ModuleRoot $env:PSModulePath = Get-CiIsolatedModulePath -ModuleRoot $owner.Path $env:PATH = (Split-Path -Parent (Get-Process -Id $PID).Path) + [IO.Path]::PathSeparator + $saved.PATH $resolved = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source - if ($resolved -ne (Get-Process -Id $PID).Path) { throw 'pwsh command resolution does not match the selected executable.' } + if ($resolved -ne (Get-Process -Id $PID).Path) { + throw 'pwsh command resolution does not match the selected executable.' + } & (Get-Process -Id $PID).Path -NoLogo -NoProfile -File (Join-Path $PSScriptRoot 'CiDependencyProbe.ps1') -ModuleRoot $owner.Path - if ($LASTEXITCODE -ne 0) { throw 'Dependency validation failed.' } + if ($LASTEXITCODE -ne 0) { + throw 'Dependency validation failed.' + } $status = Invoke-CiLane -LaneId $LaneId -ExpectedPowerShellVersion $ExpectedPowerShellVersion -ProvisioningFile $ProvisioningFile -RepositoryRoot $RepositoryRoot } finally { - foreach ($key in $saved.Keys) { [Environment]::SetEnvironmentVariable($key, $saved[$key]) } - if ($owner) { Remove-CiDependencyRoot -Owner $owner } + foreach ($key in $saved.Keys) { + [Environment]::SetEnvironmentVariable($key, $saved[$key]) + } + if ($owner) { + Remove-CiDependencyRoot -Owner $owner + } Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop } if ($status -ne 'passed') { diff --git a/scripts/build/ci/Repair-CodeSceneJaCoCoCoverage.ps1 b/scripts/build/ci/Repair-CodeSceneJaCoCoCoverage.ps1 index 2d6fea5..4fc885c 100644 --- a/scripts/build/ci/Repair-CodeSceneJaCoCoCoverage.ps1 +++ b/scripts/build/ci/Repair-CodeSceneJaCoCoCoverage.ps1 @@ -50,8 +50,7 @@ function Repair-CodeSceneJaCoCoCoverageFile { $writer = [System.Xml.XmlWriter]::Create($Path, $settings) try { $document.Save($writer) - } - finally { + } finally { $writer.Dispose() } diff --git a/scripts/build/ci/Write-CiTestRuntimeEvidence.ps1 b/scripts/build/ci/Write-CiTestRuntimeEvidence.ps1 index 70d44c0..61993e1 100644 --- a/scripts/build/ci/Write-CiTestRuntimeEvidence.ps1 +++ b/scripts/build/ci/Write-CiTestRuntimeEvidence.ps1 @@ -1,7 +1,9 @@ . (Join-Path $PSScriptRoot 'CiRuntimeEvidence.ps1') function Write-CiTestRuntimeEvidence { - if (-not $env:PMU_CI_TEST_RUNTIME_PATH) { return } + if (-not $env:PMU_CI_TEST_RUNTIME_PATH) { + return + } $context = Get-Content -LiteralPath $env:PMU_CI_RUNTIME_CONTEXT -Raw -ErrorAction Stop | ConvertFrom-Json $record = Get-CiRuntimeRecord Assert-CiRuntimeRecord -Record $record -Expected $context.Expected diff --git a/scripts/qualification/Invoke-QualificationChild.ps1 b/scripts/qualification/Invoke-QualificationChild.ps1 index 9606f70..d6f2fea 100644 --- a/scripts/qualification/Invoke-QualificationChild.ps1 +++ b/scripts/qualification/Invoke-QualificationChild.ps1 @@ -23,17 +23,27 @@ function Invoke-QualificationSession { Invoke-QualificationScenario $Session } catch { Write-QualificationFailureEvidence $Session $_ - } finally { Complete-QualificationSession $Session } + } finally { + Complete-QualificationSession $Session + } } function Write-QualificationFailureEvidence { - param([object]$Session, [System.Management.Automation.ErrorRecord]$Failure) + param( + [object]$Session, + [System.Management.Automation.ErrorRecord]$Failure + ) + $Session.Evidence.Failure = 'Operation failed; raw authenticated diagnostics withheld.' $Session.Evidence.ErrorType = $Failure.Exception.GetType().Name $Session.Evidence.FailureLocation = "$([IO.Path]::GetFileName($Failure.InvocationInfo.ScriptName)):$($Failure.InvocationInfo.ScriptLineNumber)" $parameter = $Failure.Exception.PSObject.Properties['ParameterName'] - if ($parameter) { $Session.Evidence.FailureParameter = $parameter.Value } - if ($Failure.FullyQualifiedErrorId -match 'PMU\.[A-Za-z]+') { $Session.Evidence.ErrorId = $Matches[0] } + if ($parameter) { + $Session.Evidence.FailureParameter = $parameter.Value + } + if ($Failure.FullyQualifiedErrorId -match 'PMU\.[A-Za-z]+') { + $Session.Evidence.ErrorId = $Matches[0] + } } Invoke-QualificationChild diff --git a/scripts/qualification/Invoke-RepositoryQualification.ps1 b/scripts/qualification/Invoke-RepositoryQualification.ps1 index e6bb7dc..8f26254 100644 --- a/scripts/qualification/Invoke-RepositoryQualification.ps1 +++ b/scripts/qualification/Invoke-RepositoryQualification.ps1 @@ -29,14 +29,20 @@ $ErrorActionPreference = 'Stop' Assert-QualificationAuthorization $EnableLive $ProviderStoreApproved function Invoke-RepositoryQualification { - param([hashtable]$Options, [pscredential]$Credential) + param( + [hashtable]$Options, + [pscredential]$Credential + ) + Initialize-QualificationLaunchOption $Options $temporaryRoot = Get-QualificationTemporaryRoot try { New-Item -Path "$temporaryRoot/home" -ItemType Directory | Out-Null Invoke-QualificationChild $Options $temporaryRoot $Credential } finally { - if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction Stop } + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction Stop + } } } diff --git a/scripts/qualification/QualificationProcess.ps1 b/scripts/qualification/QualificationProcess.ps1 index ee618ff..8c2aba6 100644 --- a/scripts/qualification/QualificationProcess.ps1 +++ b/scripts/qualification/QualificationProcess.ps1 @@ -1,5 +1,10 @@ function Initialize-QualificationProcessInfo { - param([hashtable]$Options, [string]$Root, [pscredential]$Credential) + param( + [hashtable]$Options, + [string]$Root, + [pscredential]$Credential + ) + $info = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh')) foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-File', "$PSScriptRoot/Invoke-QualificationChild.ps1")) { $info.ArgumentList.Add($argument) @@ -8,12 +13,19 @@ function Initialize-QualificationProcessInfo { $info.RedirectStandardOutput = $true $info.RedirectStandardError = $true Initialize-QualificationChildEnvironment $info $Options $Root - if ($Credential) { Initialize-QualificationChildCredential $info $Credential } + if ($Credential) { + Initialize-QualificationChildCredential $info $Credential + } $info } function Initialize-QualificationChildEnvironment { - param([Diagnostics.ProcessStartInfo]$Info, [hashtable]$Options, [string]$Root) + param( + [Diagnostics.ProcessStartInfo]$Info, + [hashtable]$Options, + [string]$Root + ) + $Info.Environment['HOME'] = "$Root/home" $Info.Environment['XDG_DATA_HOME'] = "$Root/home/.local/share" $Info.Environment['XDG_CONFIG_HOME'] = "$Root/home/.config" @@ -24,13 +36,22 @@ function Initialize-QualificationChildEnvironment { } function Initialize-QualificationChildCredential { - param([Diagnostics.ProcessStartInfo]$Info, [pscredential]$Credential) + param( + [Diagnostics.ProcessStartInfo]$Info, + [pscredential]$Credential + ) + $Info.Environment['PMU_QUALIFICATION_USERNAME'] = $Credential.UserName $Info.Environment['PMU_QUALIFICATION_PASSWORD'] = $Credential.GetNetworkCredential().Password } function Invoke-QualificationChild { - param([hashtable]$Options, [string]$Root, [pscredential]$Credential) + param( + [hashtable]$Options, + [string]$Root, + [pscredential]$Credential + ) + $process = [Diagnostics.Process]::new() $process.StartInfo = Initialize-QualificationProcessInfo $Options $Root $Credential try { @@ -39,15 +60,21 @@ function Invoke-QualificationChild { $errorTask = $process.StandardError.ReadToEndAsync() Wait-QualificationChild $process [void]$errorTask.GetAwaiter().GetResult() - if ($process.ExitCode -ne 0) { throw 'Qualification child failed; raw provider errors withheld.' } + if ($process.ExitCode -ne 0) { + throw 'Qualification child failed; raw provider errors withheld.' + } $text = $outputTask.GetAwaiter().GetResult() [regex]::Match($text, '(?m)^\{.+\}\s*$').Value | ConvertFrom-Json -Depth 30 - } finally { $process.Dispose() } + } finally { + $process.Dispose() + } } function Wait-QualificationChild { param([Diagnostics.Process]$Process) - if ($Process.WaitForExit(300000)) { return } + if ($Process.WaitForExit(300000)) { + return + } $Process.Kill($true) $Process.WaitForExit() throw 'Qualification exceeded five minutes; shared-store cleanup must be checked explicitly.' @@ -57,9 +84,14 @@ function Initialize-QualificationLaunchOption { $Options.ModulePath = (Resolve-Path -LiteralPath $Options.ModulePath).Path $projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $moduleScript = [IO.Path]::ChangeExtension($Options.ModulePath, '.psm1') - if ($Options['ApprovedBuildSha256']) { Assert-QualificationModuleHash $moduleScript $Options['ApprovedBuildSha256'] } - else { Assert-QualificationBuild $moduleScript "$projectRoot/src" } - if ($Options['RepositoryUri']) { Assert-QualificationEndpoint $Options['RepositoryUri'] } + if ($Options['ApprovedBuildSha256']) { + Assert-QualificationModuleHash $moduleScript $Options['ApprovedBuildSha256'] + } else { + Assert-QualificationBuild $moduleScript "$projectRoot/src" + } + if ($Options['RepositoryUri']) { + Assert-QualificationEndpoint $Options['RepositoryUri'] + } } function Get-QualificationTemporaryRoot { @@ -68,5 +100,7 @@ function Get-QualificationTemporaryRoot { $physicalBase = & /bin/pwd -P Assert-QualificationUnlinkedPath $physicalBase Join-Path $physicalBase "pmu-qualification-$([guid]::NewGuid().ToString('N'))" - } finally { Pop-Location } + } finally { + Pop-Location + } } diff --git a/scripts/qualification/QualificationRepository.ps1 b/scripts/qualification/QualificationRepository.ps1 index 9f0ebfc..f8735cc 100644 --- a/scripts/qualification/QualificationRepository.ps1 +++ b/scripts/qualification/QualificationRepository.ps1 @@ -1,13 +1,17 @@ # Only the unique entry owned by this session may be removed. No Force, Set or Reset. function Initialize-QualificationOption { param([hashtable]$Options) - if (-not $IsMacOS) { throw 'This bounded qualification harness currently requires macOS.' } + if (-not $IsMacOS) { + throw 'This bounded qualification harness currently requires macOS.' + } $initializers = @{ Gallery = ${function:Initialize-QualificationGalleryOption} NuGet = ${function:Initialize-QualificationNuGetOption} Private = ${function:Assert-QualificationPrivateOption} } - if (-not $initializers.ContainsKey($Options.Scenario)) { throw 'Unknown qualification scenario.' } + if (-not $initializers.ContainsKey($Options.Scenario)) { + throw 'Unknown qualification scenario.' + } & $initializers[$Options.Scenario] $Options Assert-QualificationEndpoint $Options.RepositoryUri } @@ -33,9 +37,13 @@ function Initialize-QualificationNuGetOption { function Assert-QualificationPrivateOption { param([hashtable]$Options) foreach ($key in @('RepositoryUri', 'SeedName', 'SeedVersion', 'TargetVersion')) { - if (-not $Options[$key]) { throw 'Private qualification requires caller-supplied endpoint and pinned module versions.' } + if (-not $Options[$key]) { + throw 'Private qualification requires caller-supplied endpoint and pinned module versions.' + } + } + if ($Options.SeedName -notmatch '^[A-Za-z0-9_.-]+$') { + throw 'Unsafe qualification fixture name.' } - if ($Options.SeedName -notmatch '^[A-Za-z0-9_.-]+$') { throw 'Unsafe qualification fixture name.' } Assert-QualificationFixtureVersion $Options.SeedVersion Assert-QualificationFixtureVersion $Options.TargetVersion $Options.DiscoveryName = $Options.SeedName @@ -43,7 +51,9 @@ function Assert-QualificationPrivateOption { function Assert-QualificationFixtureVersion { param([string]$Version) - if ($Version -notmatch '^\d+\.\d+\.\d+([.-][A-Za-z0-9.-]+)?$') { throw 'Unsafe qualification fixture version.' } + if ($Version -notmatch '^\d+\.\d+\.\d+([.-][A-Za-z0-9.-]+)?$') { + throw 'Unsafe qualification fixture version.' + } } function Initialize-QualificationRegistration { @@ -52,7 +62,9 @@ function Initialize-QualificationRegistration { $Session.RepositoryName = "PMUQualification-$([guid]::NewGuid().ToString('N'))" $Session.Evidence.OwnedRegistrationName = $Session.RepositoryName $apiVersion = 'V3' - if ($Session.Options.Scenario -eq 'Gallery') { $apiVersion = 'V2' } + if ($Session.Options.Scenario -eq 'Gallery') { + $apiVersion = 'V2' + } $Session.RegistrationOwned = $true Microsoft.PowerShell.PSResourceGet\Register-PSResourceRepository -Name $Session.RepositoryName -Uri $Session.Options.RepositoryUri -ApiVersion $apiVersion -ErrorAction Stop $Session.Evidence.RepositorySelection = 'Unique harness-owned consumer registration; URI must resolve to this registration.' @@ -60,9 +72,13 @@ function Initialize-QualificationRegistration { function Invoke-QualificationRegistrationCleanup { param([object]$Session) - if (-not $Session.RegistrationOwned) { return } + if (-not $Session.RegistrationOwned) { + return + } $repository = Microsoft.PowerShell.PSResourceGet\Get-PSResourceRepository -Name $Session.RepositoryName -ErrorAction Stop - if ("$($repository.Uri)" -cne $Session.Options.RepositoryUri) { throw 'Owned registration endpoint changed; manual cleanup required.' } + if ("$($repository.Uri)" -cne $Session.Options.RepositoryUri) { + throw 'Owned registration endpoint changed; manual cleanup required.' + } Microsoft.PowerShell.PSResourceGet\Unregister-PSResourceRepository -Name $Session.RepositoryName -ErrorAction Stop $Session.RegistrationOwned = $false } diff --git a/scripts/qualification/QualificationSafety.ps1 b/scripts/qualification/QualificationSafety.ps1 index 3203f1a..c6fb667 100644 --- a/scripts/qualification/QualificationSafety.ps1 +++ b/scripts/qualification/QualificationSafety.ps1 @@ -1,21 +1,39 @@ # Harness-only safety boundary. No provider or installation operations belong here. function Assert-QualificationAuthorization { - param([bool]$EnableLive, [bool]$ProviderStoreApproved) - if (-not $EnableLive) { throw 'Live qualification requires explicit opt-in.' } - if (-not $ProviderStoreApproved) { throw 'Shared provider store access requires coordinator approval.' } + param( + [bool]$EnableLive, + [bool]$ProviderStoreApproved + ) + + if (-not $EnableLive) { + throw 'Live qualification requires explicit opt-in.' + } + if (-not $ProviderStoreApproved) { + throw 'Shared provider store access requires coordinator approval.' + } } function Assert-QualificationEndpoint { param([uri]$Uri) - if (-not $Uri.IsAbsoluteUri) { throw 'Unsafe qualification endpoint.' } - if ($Uri.Scheme -ne 'https') { throw 'Unsafe qualification endpoint.' } + if (-not $Uri.IsAbsoluteUri) { + throw 'Unsafe qualification endpoint.' + } + if ($Uri.Scheme -ne 'https') { + throw 'Unsafe qualification endpoint.' + } foreach ($unsafePart in @($Uri.UserInfo, $Uri.Query, $Uri.Fragment)) { - if ($unsafePart) { throw 'Unsafe qualification endpoint.' } + if ($unsafePart) { + throw 'Unsafe qualification endpoint.' + } } } function Assert-QualificationDestination { - param([string]$Destination, [string]$HomeRoot) + param( + [string]$Destination, + [string]$HomeRoot + ) + $homePath = [IO.Path]::GetFullPath($HomeRoot).TrimEnd([IO.Path]::DirectorySeparatorChar) $targetPath = [IO.Path]::GetFullPath($Destination) $homeBoundary = $homePath + [IO.Path]::DirectorySeparatorChar @@ -31,14 +49,20 @@ function Assert-QualificationUnlinkedPath { while ($current) { if (Test-Path -LiteralPath $current) { $item = Get-Item -LiteralPath $current -Force - if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Qualification path contains a link.' } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'Qualification path contains a link.' + } } $current = Split-Path -Parent $current } } function Assert-QualificationRepositoryState { - param([string[]]$Before, [string[]]$After) + param( + [string[]]$Before, + [string[]]$After + ) + $beforeText = ($Before | Sort-Object) -join "`n" $afterText = ($After | Sort-Object) -join "`n" if ($beforeText -cne $afterText) { @@ -48,46 +72,83 @@ function Assert-QualificationRepositoryState { function Assert-QualificationPublicObject { param([object]$Value) - if ($null -eq $Value) { return } + if ($null -eq $Value) { + return + } Assert-QualificationValueType $Value - if ($Value -is [uri]) { Assert-QualificationEndpoint $Value; return } - if ($Value -is [string]) { return } - if ($Value.GetType().IsValueType) { return } + if ($Value -is [uri]) { + Assert-QualificationEndpoint $Value + return + } + if ($Value -is [string]) { + return + } + if ($Value.GetType().IsValueType) { + return + } Assert-QualificationComposite $Value } function Assert-QualificationValueType { param([object]$Value) - if ($Value -is [pscredential]) { throw 'Public result contains a credential.' } - if ($Value -is [securestring]) { throw 'Public result contains a credential.' } + if ($Value -is [pscredential]) { + throw 'Public result contains a credential.' + } + if ($Value -is [securestring]) { + throw 'Public result contains a credential.' + } } function Assert-QualificationComposite { param([object]$Value) - if ($Value -is [System.Collections.IDictionary]) { Assert-QualificationDictionary $Value; return } - if ($Value -is [System.Collections.IEnumerable]) { Assert-QualificationSequence $Value; return } - foreach ($property in $Value.PSObject.Properties) { Assert-QualificationPublicField $property.Name $property.Value } + if ($Value -is [System.Collections.IDictionary]) { + Assert-QualificationDictionary $Value + return + } + if ($Value -is [System.Collections.IEnumerable]) { + Assert-QualificationSequence $Value + return + } + foreach ($property in $Value.PSObject.Properties) { + Assert-QualificationPublicField $property.Name $property.Value + } } function Assert-QualificationDictionary { param([System.Collections.IDictionary]$Value) - foreach ($key in $Value.Keys) { Assert-QualificationPublicField $key $Value[$key] } + foreach ($key in $Value.Keys) { + Assert-QualificationPublicField $key $Value[$key] + } } function Assert-QualificationSequence { param([System.Collections.IEnumerable]$Value) - foreach ($entry in $Value) { Assert-QualificationPublicObject $entry } + foreach ($entry in $Value) { + Assert-QualificationPublicObject $entry + } } function Assert-QualificationPublicField { - param([string]$Name, [object]$Value) - if ($Name -match '(?i)credential|password|token|secret|authorization') { throw 'Public result contains a credential field.' } - if ($Name -match 'Uri$' -and $Value) { Assert-QualificationEndpoint ([uri]$Value) } + param( + [string]$Name, + [object]$Value + ) + + if ($Name -match '(?i)credential|password|token|secret|authorization') { + throw 'Public result contains a credential field.' + } + if ($Name -match 'Uri$' -and $Value) { + Assert-QualificationEndpoint ([uri]$Value) + } Assert-QualificationPublicObject $Value } function Assert-QualificationBuild { - param([string]$ModuleScript, [string]$SourceRoot) + param( + [string]$ModuleScript, + [string]$SourceRoot + ) + $builtContent = [IO.File]::ReadAllText($ModuleScript).Replace("`r`n", "`n") foreach ($sourceFile in Get-ChildItem $SourceRoot -Filter '*.ps1' -File -Recurse) { $sourceContent = [IO.File]::ReadAllText($sourceFile.FullName).Replace("`r`n", "`n").Trim() @@ -99,8 +160,14 @@ function Assert-QualificationBuild { } function Assert-QualificationModuleHash { - param([string]$ModuleScript, [string]$ApprovedHash) - if ($ApprovedHash -notmatch '^[A-Fa-f0-9]{64}$') { throw 'Invalid approved build identity.' } + param( + [string]$ModuleScript, + [string]$ApprovedHash + ) + + if ($ApprovedHash -notmatch '^[A-Fa-f0-9]{64}$') { + throw 'Invalid approved build identity.' + } if ((Get-FileHash -LiteralPath $ModuleScript -Algorithm SHA256).Hash -ine $ApprovedHash) { throw 'The module does not match the explicitly approved build.' } diff --git a/scripts/qualification/QualificationScenario.ps1 b/scripts/qualification/QualificationScenario.ps1 index 4c53cb2..5295519 100644 --- a/scripts/qualification/QualificationScenario.ps1 +++ b/scripts/qualification/QualificationScenario.ps1 @@ -2,7 +2,10 @@ function Invoke-QualificationScenario { param([object]$Session) $Session.Evidence.Phase = 'ProviderDiscovery' Get-QualificationDiscoveryEvidence $Session - if ($Session.Options.Scenario -eq 'NuGet') { Invoke-QualificationNonModule $Session; return } + if ($Session.Options.Scenario -eq 'NuGet') { + Invoke-QualificationNonModule $Session + return + } $module = Save-QualificationSeed $Session $Session.Evidence.Phase = 'PMUPlanning' Test-QualificationSourcePolicy $Session $module @@ -18,7 +21,9 @@ function Get-QualificationDiscoveryEvidence { param([object]$Session) $parameters = Get-QualificationProviderParameter $Session $Session.Options.SeedName $Session.Options.TargetVersion $exact = @(Microsoft.PowerShell.PSResourceGet\Find-PSResource @parameters) - if ($exact.Count -ne 1) { throw 'Exact provider fixture was not found.' } + if ($exact.Count -ne 1) { + throw 'Exact provider fixture was not found.' + } $Session.Evidence.ExactDiscoveredVersion = "$($exact[0].Version)" $parameters = Get-QualificationProviderParameter $Session $Session.Options.DiscoveryName '*' $parameters.Prerelease = $true @@ -41,31 +46,61 @@ function Save-QualificationSeed { } function Assert-QualificationSeed { - param([object]$Session, [System.Management.Automation.PSModuleInfo]$Module) - if (-not $Module) { throw 'The downloaded seed is not an available PowerShell module.' } - if (@($Module.RequiredModules | Where-Object Name -EQ 'PSModuleUpdate').Count) { throw 'Fixture unexpectedly depends on PMU.' } - if (Get-Module -Name $Module.Name) { throw 'The seed fixture must remain unloaded.' } + param( + [object]$Session, + [System.Management.Automation.PSModuleInfo]$Module + ) + + if (-not $Module) { + throw 'The downloaded seed is not an available PowerShell module.' + } + if (@($Module.RequiredModules | Where-Object Name -EQ 'PSModuleUpdate').Count) { + throw 'Fixture unexpectedly depends on PMU.' + } + if (Get-Module -Name $Module.Name) { + throw 'The seed fixture must remain unloaded.' + } $Session.Evidence.SeedHasPmuDependency = $false $Session.Evidence.SeedWasLoaded = $false } function Get-QualificationPlan { - param([object]$Session, [System.Management.Automation.PSModuleInfo]$Module) - $parameters = @{ Module = $Module; Policy = (Get-QualificationPolicy $Session) } - if ($Session.Credential) { $parameters.Credential = $Session.Credential } - if ($Session.Options.Scenario -eq 'Gallery') { $parameters.Repository = $Session.RepositoryName } - else { $parameters.RepositoryUri = [uri]$Session.Options.RepositoryUri } + param( + [object]$Session, + [System.Management.Automation.PSModuleInfo]$Module + ) + + $parameters = @{ + Module = $Module + Policy = (Get-QualificationPolicy $Session) + } + if ($Session.Credential) { + $parameters.Credential = $Session.Credential + } + if ($Session.Options.Scenario -eq 'Gallery') { + $parameters.Repository = $Session.RepositoryName + } else { + $parameters.RepositoryUri = [uri]$Session.Options.RepositoryUri + } $plan = Get-PSModuleUpdate @parameters Assert-QualificationPublicObject $plan - if ($plan.TargetVersion -cne $Session.Options.TargetVersion) { throw 'PMU selected a different exact version.' } - if ($plan.RepositoryUri -cne $Session.Options.RepositoryUri) { throw 'PMU changed the selected source.' } + if ($plan.TargetVersion -cne $Session.Options.TargetVersion) { + throw 'PMU selected a different exact version.' + } + if ($plan.RepositoryUri -cne $Session.Options.RepositoryUri) { + throw 'PMU changed the selected source.' + } Assert-QualificationPlanIdentity $Session $Module $plan $Session.Evidence.Plan = $plan $plan } function Test-QualificationWhatIf { - param([object]$Session, [object]$Plan) + param( + [object]$Session, + [object]$Plan + ) + $before = @(Get-QualificationModuleTree $Session.Destination) $result = Install-PSModuleUpdate -Update $Plan -WhatIf -Credential $Session.Credential -InformationAction SilentlyContinue Assert-QualificationRepositoryState $before @(Get-QualificationModuleTree $Session.Destination) @@ -74,13 +109,24 @@ function Test-QualificationWhatIf { } function Assert-QualificationInstalledResult { - param([object]$Session, [object]$Plan, [object]$Result) + param( + [object]$Session, + [object]$Plan, + [object]$Result + ) + Assert-QualificationPublicObject $Result foreach ($field in @('ModuleName', 'ModuleGuid', 'RepositoryUri', 'Scope', 'ActiveVersion', 'TargetVersion')) { - if ($Result.$field -cne $Plan.$field) { throw 'Installation result does not match the source-bound plan.' } + if ($Result.$field -cne $Plan.$field) { + throw 'Installation result does not match the source-bound plan.' + } + } + if ($Result.InstalledVersion -cne $Plan.TargetVersion) { + throw 'The exact target version was not installed.' + } + if ($Result.VerificationStatus -ne 'Verified') { + throw 'The installed package was not verified.' } - if ($Result.InstalledVersion -cne $Plan.TargetVersion) { throw 'The exact target version was not installed.' } - if ($Result.VerificationStatus -ne 'Verified') { throw 'The installed package was not verified.' } Assert-QualificationResultStatus $Result Assert-QualificationDestination $Result.InstalledPath "$($Session.Root)/home" $Session.Evidence.Result = $Result @@ -88,39 +134,68 @@ function Assert-QualificationInstalledResult { } function Assert-QualificationPlanIdentity { - param([object]$Session, [System.Management.Automation.PSModuleInfo]$Module, [object]$Plan) - if ($Plan.ModuleGuid -ne $Module.Guid) { throw 'Plan identity differs from the independent installed module.' } - if ($Plan.InstalledVersion -cne $Session.Options.SeedVersion) { throw 'Plan observed a different installed version.' } + param( + [object]$Session, + [System.Management.Automation.PSModuleInfo]$Module, + [object]$Plan + ) + + if ($Plan.ModuleGuid -ne $Module.Guid) { + throw 'Plan identity differs from the independent installed module.' + } + if ($Plan.InstalledVersion -cne $Session.Options.SeedVersion) { + throw 'Plan observed a different installed version.' + } Assert-QualificationUnloadedState $Plan - if ($Plan.Scope -ne 'CurrentUser') { throw 'Plan changed the approved isolated scope.' } - if (-not $Plan.UpdateAvailable) { throw 'The pinned newer target must be available.' } + if ($Plan.Scope -ne 'CurrentUser') { + throw 'Plan changed the approved isolated scope.' + } + if (-not $Plan.UpdateAvailable) { + throw 'The pinned newer target must be available.' + } } function Assert-QualificationResultStatus { param([object]$Result) - if ($Result.SchemaVersion -ne 1) { throw 'Unexpected public result schema.' } - if ($Result.Outcome -ne 'Installed') { throw 'Expected a verified new installation.' } - if ($Result.RestartRequired) { throw 'An unloaded module should not require restart.' } + if ($Result.SchemaVersion -ne 1) { + throw 'Unexpected public result schema.' + } + if ($Result.Outcome -ne 'Installed') { + throw 'Expected a verified new installation.' + } + if ($Result.RestartRequired) { + throw 'An unloaded module should not require restart.' + } Assert-QualificationUnloadedState $Result - if (Get-Module -Name $Result.ModuleName) { throw 'PMU unexpectedly loaded the independent target.' } + if (Get-Module -Name $Result.ModuleName) { + throw 'PMU unexpectedly loaded the independent target.' + } } function Assert-QualificationUnloadedState { param([object]$Value) foreach ($field in @('ActiveVersion', 'ActivePath', 'ActiveScope')) { $property = $Value.PSObject.Properties[$field] - if ($property -and $null -ne $property.Value) { throw 'An unloaded fixture must not report actual active state.' } + if ($property -and $null -ne $property.Value) { + throw 'An unloaded fixture must not report actual active state.' + } } } function Test-QualificationSourcePolicy { - param([object]$Session, [System.Management.Automation.PSModuleInfo]$Module) + param( + [object]$Session, + [System.Management.Automation.PSModuleInfo]$Module + ) + $policy = Get-QualificationPolicy $Session $policy.ExpectedRepositoryUri = [uri]'https://qualification.invalid/not-the-approved-source' try { Get-PSModuleUpdate -Module $Module -Repository $Session.RepositoryName -Policy $policy -Credential $Session.Credential | Out-Null } catch { - if ($_.FullyQualifiedErrorId -notmatch 'PMU\.RepositoryChanged') { throw 'Unexpected source-policy rejection.' } + if ($_.FullyQualifiedErrorId -notmatch 'PMU\.RepositoryChanged') { + throw 'Unexpected source-policy rejection.' + } $Session.Evidence.SourcePolicyMismatchRejected = $true return } @@ -130,7 +205,9 @@ function Test-QualificationSourcePolicy { function Invoke-QualificationNonModule { param([object]$Session) $Session.Evidence.Phase = 'ProviderExactDownload' - if (-not (Save-QualificationNonModuleArchive $Session)) { return } + if (-not (Save-QualificationNonModuleArchive $Session)) { + return + } $module = Initialize-QualificationNonModuleSeed $Session $before = @(Get-QualificationModuleTree $Session.Destination) $Session.Evidence.Phase = 'PMUNonModuleValidation' @@ -138,7 +215,9 @@ function Invoke-QualificationNonModule { $plan = Get-QualificationPlan $Session $module Install-PSModuleUpdate -Update $plan -Credential $Session.Credential -AcceptLicense -TrustRepository -Confirm:$false | Out-Null throw 'PMU did not reject the nonmodule package.' - } catch { Resolve-QualificationNonModuleRejection $Session $_ } + } catch { + Resolve-QualificationNonModuleRejection $Session $_ + } Assert-QualificationRepositoryState $before @(Get-QualificationModuleTree $Session.Destination) $Session.Evidence.InstallationUnchanged = $true } @@ -169,7 +248,11 @@ function Initialize-QualificationNonModuleSeed { } function Resolve-QualificationNonModuleRejection { - param([object]$Session, [System.Management.Automation.ErrorRecord]$Failure) + param( + [object]$Session, + [System.Management.Automation.ErrorRecord]$Failure + ) + if ($Failure.FullyQualifiedErrorId -match 'PMU\.(ModuleIdentityMismatch|VerificationFailed|GuidMismatch)') { $Session.Evidence.Outcome = 'NonModuleRejected' $Session.Evidence.RejectionId = $Matches[0] diff --git a/scripts/qualification/QualificationSession.ps1 b/scripts/qualification/QualificationSession.ps1 index d1404f2..f6a93f6 100644 --- a/scripts/qualification/QualificationSession.ps1 +++ b/scripts/qualification/QualificationSession.ps1 @@ -6,11 +6,20 @@ function Initialize-QualificationSession { Import-Module $Options.ModulePath -ErrorAction Stop Assert-QualificationJournalRoot "$root/home" [pscustomobject]@{ - Options = $Options; Root = $root; Destination = $destination - Credential = (Get-QualificationCredential $Options.Scenario); RepositoryName = $null + Options = $Options + Root = $root + Destination = $destination + Credential = (Get-QualificationCredential $Options.Scenario) + RepositoryName = $null RepositoryStore = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSResourceGet/PSResourceRepository.xml') - Before = @(Get-QualificationRepositoryState); RegistrationOwned = $false - Evidence = [ordered]@{ Scenario = $Options.Scenario; Outcome = 'Failed'; Cleanup = 'NotRun'; Phase = 'Initialization' } + Before = @(Get-QualificationRepositoryState) + RegistrationOwned = $false + Evidence = [ordered]@{ + Scenario = $Options.Scenario + Outcome = 'Failed' + Cleanup = 'NotRun' + Phase = 'Initialization' + } } } @@ -23,7 +32,9 @@ function Assert-QualificationJournalRoot { function Get-QualificationCredential { param([string]$Scenario) try { - if ($Scenario -eq 'Private') { Get-QualificationPrivateCredential } + if ($Scenario -eq 'Private') { + Get-QualificationPrivateCredential + } } finally { Remove-Item Env:ADO_PAT, Env:PMU_QUALIFICATION_PASSWORD, Env:PMU_QUALIFICATION_USERNAME -ErrorAction SilentlyContinue } @@ -49,7 +60,9 @@ function Get-QualificationPrivateCredential { function ConvertTo-QualificationSecret { param([string]$Value) $secure = [securestring]::new() - foreach ($character in $Value.ToCharArray()) { $secure.AppendChar($character) } + foreach ($character in $Value.ToCharArray()) { + $secure.AppendChar($character) + } $secure.MakeReadOnly() $secure } @@ -76,19 +89,32 @@ function Complete-QualificationSession { } function Get-QualificationProviderParameter { - param([object]$Session, [string]$Name, [string]$Version) + param( + [object]$Session, + [string]$Name, + [string]$Version + ) + $parameters = @{ - Name = $Name; Repository = $Session.RepositoryName; Version = $Version - ErrorAction = 'Stop'; WarningAction = 'SilentlyContinue'; Verbose = $false; Debug = $false + Name = $Name + Repository = $Session.RepositoryName + Version = $Version + ErrorAction = 'Stop' + WarningAction = 'SilentlyContinue' + Verbose = $false + Debug = $false + } + if ($Session.Credential) { + $parameters.Credential = $Session.Credential } - if ($Session.Credential) { $parameters.Credential = $Session.Credential } $parameters } function Get-QualificationPolicy { param([object]$Session) @{ - Channel = 'Stable'; Scope = 'CurrentUser' + Channel = 'Stable' + Scope = 'CurrentUser' ExpectedRepositoryUri = [uri]$Session.Options.RepositoryUri VersionRange = "[$($Session.Options.TargetVersion)]" } diff --git a/src/private/identity/Assert-ManifestRootModule.ps1 b/src/private/identity/Assert-ManifestRootModule.ps1 index ac6ab7e..2ef4c71 100644 --- a/src/private/identity/Assert-ManifestRootModule.ps1 +++ b/src/private/identity/Assert-ManifestRootModule.ps1 @@ -1,6 +1,12 @@ function Assert-ManifestRootModule { - param([hashtable]$Data, [string]$ManifestPath) - if (-not $Data['RootModule']) { return } + param( + [hashtable]$Data, + [string]$ManifestPath + ) + + if (-not $Data['RootModule']) { + return + } $root = Split-Path -Parent $ManifestPath $path = Assert-SafePath (Join-Path $root $Data['RootModule']) $relative = [IO.Path]::GetRelativePath($root, $path) diff --git a/src/private/identity/Assert-ReferenceInstallationLayout.ps1 b/src/private/identity/Assert-ReferenceInstallationLayout.ps1 index b420296..87a0321 100644 --- a/src/private/identity/Assert-ReferenceInstallationLayout.ps1 +++ b/src/private/identity/Assert-ReferenceInstallationLayout.ps1 @@ -1,6 +1,13 @@ function Assert-ReferenceInstallationLayout { - param([object]$Reference, [object[]]$Observed, [object]$RecoveryJournal) + param( + [object]$Reference, + [object[]]$Observed, + [object]$RecoveryJournal + ) + $installations = @($Observed | Where-Object ManifestPath -CEQ $Reference.ManifestPath) - if ($installations.Count -eq 1) { return } + if ($installations.Count -eq 1) { + return + } Assert-BackupReferenceIdentity $Reference $RecoveryJournal } diff --git a/src/private/identity/Assert-SelectedInstallation.ps1 b/src/private/identity/Assert-SelectedInstallation.ps1 index 89a42d7..191455d 100644 --- a/src/private/identity/Assert-SelectedInstallation.ps1 +++ b/src/private/identity/Assert-SelectedInstallation.ps1 @@ -1,5 +1,9 @@ function Assert-SelectedInstallation { - param([object[]]$Installations, [guid]$Guid) + param( + [object[]]$Installations, + [guid]$Guid + ) + if (-not $Installations.Count) { Write-PMUError 'PMU.UnsupportedInstallation' 'No existing installation was found in the requested scope.' } diff --git a/src/private/identity/ConvertTo-InstallationObservation.ps1 b/src/private/identity/ConvertTo-InstallationObservation.ps1 new file mode 100644 index 0000000..53a55ef --- /dev/null +++ b/src/private/identity/ConvertTo-InstallationObservation.ps1 @@ -0,0 +1,19 @@ +function ConvertTo-InstallationObservation { + param( + [object]$Identity, + [object]$Scope, + [object]$Root, + [object]$ManifestPath + ) + + # Identity is a verified manifest identity with Version and Guid; expose only planning fields. + [pscustomobject]@{ + FullVersion = $Identity.Version.FullVersion + NumericVersion = $Identity.Version.NumericVersion + Prerelease = $Identity.Version.Prerelease + Scope = $Scope + Root = $Root + ManifestPath = $ManifestPath + Guid = $Identity.Guid + } +} diff --git a/src/private/identity/Get-ActiveModuleIdentity.ps1 b/src/private/identity/Get-ActiveModuleIdentity.ps1 index b3e0616..40e2539 100644 --- a/src/private/identity/Get-ActiveModuleIdentity.ps1 +++ b/src/private/identity/Get-ActiveModuleIdentity.ps1 @@ -1,10 +1,14 @@ function Get-ActiveModuleIdentity { param([System.Management.Automation.PSModuleInfo]$Module) $loaded = @(Get-Module -Name $Module.Name | Where-Object Guid -EQ $Module.Guid | Select-Object -First 1) - if (-not $loaded.Count) { return $null } + if (-not $loaded.Count) { + return $null + } $active = $loaded[0] [pscustomobject]@{ - Name = $active.Name; Guid = $active.Guid; Version = Get-ActiveModuleVersion $active + Name = $active.Name + Guid = $active.Guid + Version = Get-ActiveModuleVersion $active ManifestPath = Assert-SafePath (Join-Path $active.ModuleBase "$($active.Name).psd1") } } diff --git a/src/private/identity/Get-ActiveModuleVersion.ps1 b/src/private/identity/Get-ActiveModuleVersion.ps1 index e52b203..15b5565 100644 --- a/src/private/identity/Get-ActiveModuleVersion.ps1 +++ b/src/private/identity/Get-ActiveModuleVersion.ps1 @@ -4,8 +4,12 @@ function Get-ActiveModuleVersion { $label = '' if ($Module.PrivateData -is [System.Collections.IDictionary]) { $privateData = $Module.PrivateData - if ($privateData['PSData']) { $label = "$($privateData['PSData']['Prerelease'])" } + if ($privateData['PSData']) { + $label = "$($privateData['PSData']['Prerelease'])" + } + } + if ($label) { + $full += "-$label" } - if ($label) { $full += "-$label" } ConvertTo-CompleteModuleVersion $full } diff --git a/src/private/identity/Get-InstalledModuleIdentityList.ps1 b/src/private/identity/Get-InstalledModuleIdentityList.ps1 index 18f8d78..6a3ac12 100644 --- a/src/private/identity/Get-InstalledModuleIdentityList.ps1 +++ b/src/private/identity/Get-InstalledModuleIdentityList.ps1 @@ -1,14 +1,23 @@ function Get-InstalledModuleIdentityList { - param([string]$Name, [string]$Scope, [string]$ExcludedPath) + param( + [string]$Name, + [string]$Scope, + [string]$ExcludedPath + ) + $root = Resolve-ModuleInstallationRoot $Scope $directory = Assert-SafePath (Join-Path $root $Name) - if (-not (Test-Path -LiteralPath $directory)) { return } - foreach ($child in Get-ChildItem -LiteralPath $directory -Directory -Force) { - if ($child.FullName -ceq $ExcludedPath) { continue } + if (-not (Test-Path -LiteralPath $directory)) { + return + } + $candidates = Get-ChildItem -LiteralPath $directory -Directory -Force | Where-Object { Test-InstallationDirectoryCandidate $_ $Name } + $candidates = $candidates | Where-Object FullName -CNE $ExcludedPath + foreach ($child in $candidates) { $identity = Get-ManifestIdentity (Join-Path $child.FullName "$Name.psd1") if ($child.Name -cne $identity.Version.NumericVersion) { Write-PMUError 'PMU.UnsupportedInstallation' 'Only provider numeric-version directory layouts are supported.' } - [pscustomobject]@{ FullVersion = $identity.Version.FullVersion; NumericVersion = $identity.Version.NumericVersion; Prerelease = $identity.Version.Prerelease; Scope = $Scope; Root = $root; ManifestPath = $identity.ManifestPath; Guid = $identity.Guid } + + ConvertTo-InstallationObservation $identity $Scope $root $identity.ManifestPath } } diff --git a/src/private/identity/Get-ManifestIdentity.ps1 b/src/private/identity/Get-ManifestIdentity.ps1 index 8daf563..f1f93cb 100644 --- a/src/private/identity/Get-ManifestIdentity.ps1 +++ b/src/private/identity/Get-ManifestIdentity.ps1 @@ -1,12 +1,12 @@ function Get-ManifestIdentity { param([string]$Path) $safe = Assert-SafePath $Path - try { $data = Import-PowerShellDataFile -LiteralPath $safe -ErrorAction Stop } - catch { Write-PMUError 'PMU.ModuleIdentityMismatch' 'The module manifest cannot be safely read.' } - if (-not $data['GUID'] -or -not $data['ModuleVersion']) { - Write-PMUError 'PMU.ModuleIdentityMismatch' 'The module manifest must declare version and GUID.' - } - Assert-ManifestRootModule $data $safe + $data = Read-ManifestDefinition -ManifestPath $safe $version = Get-ManifestModuleVersion $data - [pscustomobject]@{ Name = [IO.Path]::GetFileNameWithoutExtension($safe); Guid = [guid]$data.GUID; Version = $version; ManifestPath = $safe } + [pscustomobject]@{ + Name = [IO.Path]::GetFileNameWithoutExtension($safe) + Guid = [guid]$data.GUID + Version = $version + ManifestPath = $safe + } } diff --git a/src/private/identity/Get-ManifestModuleVersion.ps1 b/src/private/identity/Get-ManifestModuleVersion.ps1 index 7dc3973..fd5273f 100644 --- a/src/private/identity/Get-ManifestModuleVersion.ps1 +++ b/src/private/identity/Get-ManifestModuleVersion.ps1 @@ -1,8 +1,12 @@ function Get-ManifestModuleVersion { param([hashtable]$Data) $label = '' - if ($Data['PrivateData'] -and $Data['PrivateData']['PSData']) { $label = "$($Data['PrivateData']['PSData']['Prerelease'])" } + if ($Data['PrivateData'] -and $Data['PrivateData']['PSData']) { + $label = "$($Data['PrivateData']['PSData']['Prerelease'])" + } $full = "$($Data['ModuleVersion'])" - if ($label) { $full += "-$label" } + if ($label) { + $full += "-$label" + } ConvertTo-CompleteModuleVersion $full } diff --git a/src/private/identity/Get-ModuleReferenceIdentity.ps1 b/src/private/identity/Get-ModuleReferenceIdentity.ps1 index a3ea32a..8897bfb 100644 --- a/src/private/identity/Get-ModuleReferenceIdentity.ps1 +++ b/src/private/identity/Get-ModuleReferenceIdentity.ps1 @@ -1,6 +1,8 @@ function Get-ModuleReferenceIdentity { param([System.Management.Automation.PSModuleInfo]$Module) - if ($Module.Name -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') { Write-PMUError 'PMU.InvalidRequest' 'A literal module identity is required.' } + if ($Module.Name -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') { + Write-PMUError 'PMU.InvalidRequest' 'A literal module identity is required.' + } $identity = Get-ManifestIdentity (Join-Path $Module.ModuleBase "$($Module.Name).psd1") if ($identity.Guid -ne $Module.Guid -or [version]$identity.Version.NumericVersion -ne $Module.Version) { Write-PMUError 'PMU.ModuleIdentityMismatch' 'The supplied module reference differs from its manifest.' diff --git a/src/private/identity/Read-ManifestDefinition.ps1 b/src/private/identity/Read-ManifestDefinition.ps1 new file mode 100644 index 0000000..3e02675 --- /dev/null +++ b/src/private/identity/Read-ManifestDefinition.ps1 @@ -0,0 +1,17 @@ +function Read-ManifestDefinition { + param( + [string]$ManifestPath + ) + + # The identity caller supplies the canonical path already checked by Assert-SafePath. + try { + $data = Import-PowerShellDataFile -LiteralPath $ManifestPath -ErrorAction Stop + } catch { + Write-PMUError 'PMU.ModuleIdentityMismatch' 'The module manifest cannot be safely read.' + } + if (-not $data['GUID'] -or -not $data['ModuleVersion']) { + Write-PMUError 'PMU.ModuleIdentityMismatch' 'The module manifest must declare version and GUID.' + } + Assert-ManifestRootModule $data $ManifestPath + $data +} diff --git a/src/private/identity/Resolve-ModuleInstallationRoot.ps1 b/src/private/identity/Resolve-ModuleInstallationRoot.ps1 index f835c91..5c518b8 100644 --- a/src/private/identity/Resolve-ModuleInstallationRoot.ps1 +++ b/src/private/identity/Resolve-ModuleInstallationRoot.ps1 @@ -1,9 +1,13 @@ function Resolve-ModuleInstallationRoot { param([ValidateSet('CurrentUser', 'AllUsers')][string]$Scope) if ($IsWindows) { - if ($Scope -eq 'AllUsers') { return Assert-SafePath (Join-Path $env:ProgramFiles 'PowerShell/Modules') } + if ($Scope -eq 'AllUsers') { + return Assert-SafePath (Join-Path $env:ProgramFiles 'PowerShell/Modules') + } return Assert-SafePath (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell/Modules') } - if ($Scope -eq 'AllUsers') { return Assert-SafePath '/usr/local/share/powershell/Modules' } + if ($Scope -eq 'AllUsers') { + return Assert-SafePath '/usr/local/share/powershell/Modules' + } Assert-SafePath (Join-Path $HOME '.local/share/powershell/Modules') } diff --git a/src/private/identity/Test-InstallationDirectoryCandidate.ps1 b/src/private/identity/Test-InstallationDirectoryCandidate.ps1 new file mode 100644 index 0000000..cb2ecaa --- /dev/null +++ b/src/private/identity/Test-InstallationDirectoryCandidate.ps1 @@ -0,0 +1,9 @@ +function Test-InstallationDirectoryCandidate { + param( + [System.IO.DirectoryInfo]$Directory, + [string]$Name + ) + + $manifest = Join-Path $Directory.FullName "$Name.psd1" + $Directory.Name -match '^[0-9]+\.[0-9]+(?:\.[0-9]+){0,2}$' -or (Test-Path -LiteralPath $manifest) +} diff --git a/src/private/installation/Assert-ExactTarget.ps1 b/src/private/installation/Assert-ExactTarget.ps1 index ca9c387..cb4be06 100644 --- a/src/private/installation/Assert-ExactTarget.ps1 +++ b/src/private/installation/Assert-ExactTarget.ps1 @@ -2,9 +2,16 @@ function Assert-ExactTarget { param([object]$Operation) $plan = $Operation.Plan Assert-RepositoryBinding $plan $Operation.RepositoryName - $request = @{ Name = $plan.ModuleName; Repository = Get-OperationRepositoryName $Operation; Version = "[$($plan.TargetVersion)]"; Prerelease = $plan.TargetIsPrerelease } + $request = @{ + Name = $plan.ModuleName + Repository = Get-OperationRepositoryName $Operation + Version = "[$($plan.TargetVersion)]" + Prerelease = $plan.TargetIsPrerelease + } $resources = @(Find-ProviderResource $request $Operation.Credential | ForEach-Object { ConvertTo-ProviderRelease $_ }) $exact = @($resources | Where-Object { $_.Name -ceq $plan.ModuleName -and $_.Version.FullVersion -ceq $plan.TargetVersion }) - if ($exact.Count -ne 1) { Write-PMUError 'PMU.PackageNotFound' 'The exact approved target is no longer uniquely available.' } + if ($exact.Count -ne 1) { + Write-PMUError 'PMU.PackageNotFound' 'The exact approved target is no longer uniquely available.' + } $Operation.RequiresLicenseAcceptance = $exact[0].RequiresLicenseAcceptance } diff --git a/src/private/installation/Assert-LoadedPlanIdentity.ps1 b/src/private/installation/Assert-LoadedPlanIdentity.ps1 index e6f0840..19d2377 100644 --- a/src/private/installation/Assert-LoadedPlanIdentity.ps1 +++ b/src/private/installation/Assert-LoadedPlanIdentity.ps1 @@ -1,7 +1,15 @@ function Assert-LoadedPlanIdentity { param([object]$Operation) $active = Get-ActiveModuleIdentity $Operation.Record.Module - $actual = @{ ActivePath = $null; ActiveVersion = $null } - if ($active) { $actual = @{ ActivePath = $active.ManifestPath; ActiveVersion = $active.Version.FullVersion } } + $actual = @{ + ActivePath = $null + ActiveVersion = $null + } + if ($active) { + $actual = @{ + ActivePath = $active.ManifestPath + ActiveVersion = $active.Version.FullVersion + } + } Assert-MatchingProperty $actual $Operation.Plan @('ActivePath', 'ActiveVersion') 'PMU.PlanStale' } diff --git a/src/private/installation/Assert-NativeHandoffJournal.ps1 b/src/private/installation/Assert-NativeHandoffJournal.ps1 new file mode 100644 index 0000000..c6a027d --- /dev/null +++ b/src/private/installation/Assert-NativeHandoffJournal.ps1 @@ -0,0 +1,11 @@ +function Assert-NativeHandoffJournal { + param( + [object]$Operation, + [object]$Token + ) + + $journal = Read-RecoveryJournal $Operation + $actual = Get-NativeJournalToken $Operation $journal + Assert-MatchingProperty $actual $Token @('TransactionId', 'State', 'SHA256') 'PMU.PlanStale' + $journal +} diff --git a/src/private/installation/Assert-NativeInstallationSnapshot.ps1 b/src/private/installation/Assert-NativeInstallationSnapshot.ps1 new file mode 100644 index 0000000..91999d0 --- /dev/null +++ b/src/private/installation/Assert-NativeInstallationSnapshot.ps1 @@ -0,0 +1,14 @@ +function Assert-NativeInstallationSnapshot { + param([object]$Context) + + $operation = $Context.Operation + $expected = [pscustomobject]@{ + ModuleName = $operation.Plan.ModuleName + ModuleGuid = $operation.Plan.ModuleGuid + TargetVersion = $Context.Snapshot.Version + } + Assert-PackageIdentity (Get-ManifestIdentity (Join-Path $Context.Snapshot.Path "$($operation.Plan.ModuleName).psd1")) $expected + Assert-FileTreeEqual @($Context.Snapshot.Tree) @(Get-FileTreeInventory $Context.Snapshot.Path) 'PMU.InstalledStateChanged' + $source = Get-NativeInstalledSource $operation $Context.Snapshot.Path + Assert-MatchingProperty $source $Context.Snapshot.Source @('Name', 'Uri') 'PMU.InstalledStateChanged' +} diff --git a/src/private/installation/Assert-NativeParentLease.ps1 b/src/private/installation/Assert-NativeParentLease.ps1 new file mode 100644 index 0000000..7687d4c --- /dev/null +++ b/src/private/installation/Assert-NativeParentLease.ps1 @@ -0,0 +1,9 @@ +function Assert-NativeParentLease { + param([object]$Operation) + if ($Operation.Lock -isnot [IO.FileStream]) { + Write-PMUError 'PMU.PlanStale' 'The parent does not own the original installation lock.' + } + if (-not $Operation.Lock.CanRead -or $Operation.Lock.Name -cne $Operation.LockPath) { + Write-PMUError 'PMU.PlanStale' 'The parent no longer owns its original lock file.' + } +} diff --git a/src/private/installation/Assert-NativeResultOwnership.ps1 b/src/private/installation/Assert-NativeResultOwnership.ps1 new file mode 100644 index 0000000..9baca72 --- /dev/null +++ b/src/private/installation/Assert-NativeResultOwnership.ps1 @@ -0,0 +1,21 @@ +function Assert-NativeResultOwnership { + param( + [object]$Operation, + [object]$Context, + [object]$Result + ) + + if (-not $Result) { + Write-PMUError 'PMU.RecoveryRequired' 'The native worker returned no owned result. Pending state was preserved.' + } + $token = $Result.Token + if (-not $token) { + $token = $Context.Token + } + Assert-MatchingProperty $token $Context.Token @('TransactionId') 'PMU.PlanStale' + $journal = Assert-NativeHandoffJournal $Operation $token + if (-not $Result.Token) { + Write-NativeWorkerError $Result + } + $journal +} diff --git a/src/private/installation/Assert-NativeSelectedInstallation.ps1 b/src/private/installation/Assert-NativeSelectedInstallation.ps1 new file mode 100644 index 0000000..efb33ce --- /dev/null +++ b/src/private/installation/Assert-NativeSelectedInstallation.ps1 @@ -0,0 +1,15 @@ +function Assert-NativeSelectedInstallation { + param([object]$Context) + $operation = $Context.Operation + Assert-NativeProviderContext $operation + if ((Test-InstalledPlanState $operation) -ne $Context.AlreadyInstalled) { + Write-PMUError 'PMU.InstalledStateChanged' 'The selected installation changed during handoff.' + } + Assert-NativeInstallationSnapshot $Context + if ($Context.AlreadyInstalled) { + return + } + $parameters = Get-NativeUpdateParameter $operation + $native = @(PowerShellGet\Get-InstalledModule -Name $operation.Plan.ModuleName -AllowPrerelease:([bool]$parameters['AllowPrerelease']) -ErrorAction Stop) + Assert-NativeCandidate $operation ($native | Select-Object -First 1) +} diff --git a/src/private/installation/Assert-NativeWorkerGo.ps1 b/src/private/installation/Assert-NativeWorkerGo.ps1 new file mode 100644 index 0000000..345b62a --- /dev/null +++ b/src/private/installation/Assert-NativeWorkerGo.ps1 @@ -0,0 +1,6 @@ +function Assert-NativeWorkerGo { + param([IO.TextReader]$Reader) + if ($Reader.ReadLine() -cne 'GO') { + Write-PMUError 'PMU.PlanStale' 'The native worker did not receive an explicit live handoff.' + } +} diff --git a/src/private/installation/Assert-NativeWorkerParent.ps1 b/src/private/installation/Assert-NativeWorkerParent.ps1 new file mode 100644 index 0000000..59f247d --- /dev/null +++ b/src/private/installation/Assert-NativeWorkerParent.ps1 @@ -0,0 +1,7 @@ +function Assert-NativeWorkerParent { + param([object]$Context) + $started = Get-RepositoryProcessStart $Context.ParentPid + if (-not $started -or $started -cne $Context.ParentStarted) { + Write-PMUError 'PMU.PlanStale' 'The approving parent process instance no longer exists.' + } +} diff --git a/src/private/installation/Assert-NativeWorkerResult.ps1 b/src/private/installation/Assert-NativeWorkerResult.ps1 new file mode 100644 index 0000000..2997c39 --- /dev/null +++ b/src/private/installation/Assert-NativeWorkerResult.ps1 @@ -0,0 +1,16 @@ +function Assert-NativeWorkerResult { + param( + [object]$Operation, + [object]$Context, + [object]$Result + ) + + Assert-NativeParentLease $Operation + $Operation.Journal = Assert-NativeResultOwnership $Operation $Context $Result + if (-not $Result.Success) { + Write-NativeWorkerError $Result + } + if ($Operation.Journal.State -cne 'Verification') { + Write-PMUError 'PMU.PlanStale' 'The native verification phase was superseded.' + } +} diff --git a/src/private/installation/Assert-RequiredConsent.ps1 b/src/private/installation/Assert-RequiredConsent.ps1 index 1b4bad4..3fe0803 100644 --- a/src/private/installation/Assert-RequiredConsent.ps1 +++ b/src/private/installation/Assert-RequiredConsent.ps1 @@ -1,4 +1,12 @@ function Assert-RequiredConsent { - param([bool]$Required, [bool]$Allowed, [string]$ErrorId, [string]$Message) - if ($Required -and -not $Allowed) { Write-PMUError $ErrorId $Message } + param( + [bool]$Required, + [bool]$Allowed, + [string]$ErrorId, + [string]$Message + ) + + if ($Required -and -not $Allowed) { + Write-PMUError $ErrorId $Message + } } diff --git a/src/private/installation/Complete-NativeWorkerLease.ps1 b/src/private/installation/Complete-NativeWorkerLease.ps1 new file mode 100644 index 0000000..30b66e6 --- /dev/null +++ b/src/private/installation/Complete-NativeWorkerLease.ps1 @@ -0,0 +1,16 @@ +function Complete-NativeWorkerLease { + param( + [object]$Operation, + [object]$Worker + ) + + try { + Wait-NativeWorkerStop $Worker + } finally { + try { + Restore-NativeParentLease $Operation + } finally { + $Worker.Process.Dispose() + } + } +} diff --git a/src/private/installation/Complete-VerifiedInstallation.ps1 b/src/private/installation/Complete-VerifiedInstallation.ps1 index 507febc..9f2e86b 100644 --- a/src/private/installation/Complete-VerifiedInstallation.ps1 +++ b/src/private/installation/Complete-VerifiedInstallation.ps1 @@ -1,13 +1,21 @@ function Complete-VerifiedInstallation { - param([object]$Operation, [object[]]$Tree, [bool]$AlreadyInstalled) + param( + [object]$Operation, + [object[]]$Tree, + [bool]$AlreadyInstalled + ) + $outcome = 'AlreadyInstalled' - if ($AlreadyInstalled) { Assert-InstalledPackage $Operation $Tree } - else { - if (-not $Operation.Plan.UpdateAvailable) { Write-PMUError 'PMU.PlanStale' 'This plan does not authorize a newer target installation.' } - Initialize-Replacement $Operation $Tree - Invoke-ProviderInstallation $Operation $Tree + if (-not $AlreadyInstalled) { + if (-not $Operation.Plan.UpdateAvailable) { + Write-PMUError 'PMU.PlanStale' 'This plan does not authorize a newer target installation.' + } $outcome = 'Installed' } + Assert-InstalledPackage $Operation $Tree + if ($Operation.Journal) { + Write-RecoveryJournalState $Operation.Journal 'Completed' + } $result = ConvertTo-UpdateResult $Operation.Plan $outcome 'Verified' $result.RecoveryStatus = $Operation.RecoveryStatus Clear-CompletedOperation $Operation diff --git a/src/private/installation/Confirm-NativeExistingTarget.ps1 b/src/private/installation/Confirm-NativeExistingTarget.ps1 new file mode 100644 index 0000000..7103b28 --- /dev/null +++ b/src/private/installation/Confirm-NativeExistingTarget.ps1 @@ -0,0 +1,10 @@ +function Confirm-NativeExistingTarget { + param( + [object]$Operation, + [object[]]$Tree + ) + + Assert-InstalledPackage $Operation $Tree + Write-RecoveryJournalState $Operation.Journal 'ReplacementInProgress' + Write-RecoveryJournalState $Operation.Journal 'Verification' +} diff --git a/src/private/installation/ConvertTo-DeclinedUpdateResult.ps1 b/src/private/installation/ConvertTo-DeclinedUpdateResult.ps1 index 399ea60..162fa7f 100644 --- a/src/private/installation/ConvertTo-DeclinedUpdateResult.ps1 +++ b/src/private/installation/ConvertTo-DeclinedUpdateResult.ps1 @@ -1,6 +1,8 @@ function ConvertTo-DeclinedUpdateResult { param([object]$Plan) $outcome = 'Cancelled' - if ($WhatIfPreference) { $outcome = 'WhatIf' } + if ($WhatIfPreference) { + $outcome = 'WhatIf' + } ConvertTo-UpdateResult $Plan $outcome } diff --git a/src/private/installation/ConvertTo-InstallationOperation.ps1 b/src/private/installation/ConvertTo-InstallationOperation.ps1 index 0eec30b..4a9fbec 100644 --- a/src/private/installation/ConvertTo-InstallationOperation.ps1 +++ b/src/private/installation/ConvertTo-InstallationOperation.ps1 @@ -1,15 +1,20 @@ function ConvertTo-InstallationOperation { - param([object]$Plan, [object]$Record, [hashtable]$Consent, [pscredential]$Credential) + param( + [object]$Plan, + [object]$Record, + [hashtable]$Consent, + [pscredential]$Credential + ) + $dataRoot = Resolve-PMUDataRoot $Plan.Scope $work = Join-Path $dataRoot (Get-InstallationLockIdentity $Plan) - [pscustomobject]@{ - Plan = $Plan; Record = $Record; Consent = $Consent; Credential = $Credential + $operation = [pscustomobject]@{ + Plan = $Plan + Record = $Record + Consent = $Consent + Credential = $Credential RepositoryName = $null - DataRoot = $dataRoot; WorkRoot = $work; LockPath = Join-Path $work 'operation.lock' - JournalPath = Join-Path $work 'journal.json'; StageRoot = Join-Path $work 'stage' - BackupPath = Join-Path $work 'backup'; Reinstall = $false; Journal = $null - StagedPath = Join-Path $work "stage/$($Plan.ModuleName)/$($Plan.TargetNumericVersion)" - RecoveryStatus = 'NotRequired' - RequiresLicenseAcceptance = $false } + $operation | Add-Member -NotePropertyMembers (ConvertTo-InstallationWorkspace $Plan $dataRoot $work) + $operation } diff --git a/src/private/installation/ConvertTo-InstallationWorkspace.ps1 b/src/private/installation/ConvertTo-InstallationWorkspace.ps1 new file mode 100644 index 0000000..8db7857 --- /dev/null +++ b/src/private/installation/ConvertTo-InstallationWorkspace.ps1 @@ -0,0 +1,22 @@ +function ConvertTo-InstallationWorkspace { + param( + [object]$Plan, + [object]$DataRoot, + [object]$WorkRoot + ) + + [ordered]@{ + DataRoot = $DataRoot + WorkRoot = $WorkRoot + LockPath = Join-Path $WorkRoot 'operation.lock' + JournalPath = Join-Path $WorkRoot 'journal.json' + StageRoot = Join-Path $WorkRoot 'stage' + BackupPath = Join-Path $WorkRoot 'backup' + Reinstall = $false + Journal = $null + Lock = $null + StagedPath = Join-Path $WorkRoot "stage/$($Plan.ModuleName)/$($Plan.TargetNumericVersion)" + RecoveryStatus = 'NotRequired' + RequiresLicenseAcceptance = $false + } +} diff --git a/src/private/installation/ConvertTo-NativeWorkerContext.ps1 b/src/private/installation/ConvertTo-NativeWorkerContext.ps1 new file mode 100644 index 0000000..b15da6f --- /dev/null +++ b/src/private/installation/ConvertTo-NativeWorkerContext.ps1 @@ -0,0 +1,22 @@ +function ConvertTo-NativeWorkerContext { + param( + [object]$Operation, + [object]$Snapshot, + [bool]$AlreadyInstalled + ) + + $modules = @(Get-Module -Name PSModuleUpdate) + if ($modules.Count -ne 1) { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'The native worker requires the current built PMU module instance.' + } + $copy = ConvertTo-NativeWorkerOperation $Operation $Snapshot + [pscustomobject]@{ + Operation = $copy + Snapshot = $Snapshot + AlreadyInstalled = $AlreadyInstalled + ModulePath = $modules[0].Path + Token = Get-NativeJournalToken $Operation + ParentPid = $PID + ParentStarted = Get-RepositoryProcessStart $PID + } +} diff --git a/src/private/installation/ConvertTo-NativeWorkerOperation.ps1 b/src/private/installation/ConvertTo-NativeWorkerOperation.ps1 new file mode 100644 index 0000000..e02f156 --- /dev/null +++ b/src/private/installation/ConvertTo-NativeWorkerOperation.ps1 @@ -0,0 +1,17 @@ +function ConvertTo-NativeWorkerOperation { + param( + [object]$Operation, + [object]$Snapshot + ) + + $fields = 'Plan Consent Credential DataRoot WorkRoot LockPath JournalPath StageRoot BackupPath StagedPath Reinstall Journal RecoveryStatus RequiresLicenseAcceptance'.Split(' ') + $copy = $Operation | Select-Object -Property $fields + $copy | Add-Member RepositoryName $Snapshot.Source.Name + $copy | Add-Member Record ([pscustomobject]@{ InstalledTree = @($Operation.Record.InstalledTree) }) + $copy | Add-Member NativeContext ([pscustomobject]@{ + Root = Get-NativeProviderRoot $Operation + Paths = $null + CurrentUserRoot = Resolve-ModuleInstallationRoot 'CurrentUser' + }) + $copy +} diff --git a/src/private/installation/ConvertTo-NativeWorkerResult.ps1 b/src/private/installation/ConvertTo-NativeWorkerResult.ps1 new file mode 100644 index 0000000..e34a657 --- /dev/null +++ b/src/private/installation/ConvertTo-NativeWorkerResult.ps1 @@ -0,0 +1,21 @@ +function ConvertTo-NativeWorkerResult { + param( + [object]$Operation, + [System.Management.Automation.ErrorRecord]$Failure + ) + + $id = 'PMU.InstallationFailed' + if ($Failure -and $Failure.FullyQualifiedErrorId -match '^PMU\.[A-Za-z]+') { + $id = $Matches[0] + } + $token = $null + if ($Operation) { + $token = Get-NativeJournalToken $Operation + } + [pscustomobject]@{ + Phase = 'RESULT' + Success = -not [bool]$Failure + Token = $token + ErrorId = $id + } +} diff --git a/src/private/installation/ConvertTo-UpdateResult.ps1 b/src/private/installation/ConvertTo-UpdateResult.ps1 index 60b80f0..2e09c62 100644 --- a/src/private/installation/ConvertTo-UpdateResult.ps1 +++ b/src/private/installation/ConvertTo-UpdateResult.ps1 @@ -1,12 +1,17 @@ function ConvertTo-UpdateResult { - param([object]$Plan, [string]$Outcome, [string]$VerificationStatus = 'NotPerformed') + param( + [object]$Plan, + [string]$Outcome, + [string]$VerificationStatus = 'NotPerformed' + ) + $installed = Get-ResultInstallationState $Plan $VerificationStatus - [pscustomobject]@{ - PSTypeName = 'PSModuleUpdate.UpdateResult'; SchemaVersion = 1; Outcome = $Outcome - ModuleName = $Plan.ModuleName; ModuleGuid = $Plan.ModuleGuid; RepositoryName = $Plan.RepositoryName - RepositoryUri = $Plan.RepositoryUri; Scope = $Plan.Scope; ActiveVersion = $Plan.ActiveVersion - InstalledVersion = $installed.Version; TargetVersion = $Plan.TargetVersion; InstalledPath = $installed.Path - VerificationStatus = $VerificationStatus; RestartRequired = (Test-RestartRequired $Plan $Outcome) - DependencySummary = @($Plan.DependencySummary); RecoveryStatus = 'NotRequired'; RecoveryGuidance = '' + $result = [pscustomobject]@{ + PSTypeName = 'PSModuleUpdate.UpdateResult' + SchemaVersion = 1 + Outcome = $Outcome } + $result | Add-Member -NotePropertyMembers (ConvertTo-UpdateResultIdentity $Plan) + $result | Add-Member -NotePropertyMembers (ConvertTo-UpdateResultOutcome $Plan $installed $Outcome $VerificationStatus) + $result } diff --git a/src/private/installation/ConvertTo-UpdateResultIdentity.ps1 b/src/private/installation/ConvertTo-UpdateResultIdentity.ps1 new file mode 100644 index 0000000..d9964d8 --- /dev/null +++ b/src/private/installation/ConvertTo-UpdateResultIdentity.ps1 @@ -0,0 +1,12 @@ +function ConvertTo-UpdateResultIdentity { + param([object]$Plan) + + [ordered]@{ + ModuleName = $Plan.ModuleName + ModuleGuid = $Plan.ModuleGuid + RepositoryName = $Plan.RepositoryName + RepositoryUri = $Plan.RepositoryUri + Scope = $Plan.Scope + ActiveVersion = $Plan.ActiveVersion + } +} diff --git a/src/private/installation/ConvertTo-UpdateResultOutcome.ps1 b/src/private/installation/ConvertTo-UpdateResultOutcome.ps1 new file mode 100644 index 0000000..fddd541 --- /dev/null +++ b/src/private/installation/ConvertTo-UpdateResultOutcome.ps1 @@ -0,0 +1,19 @@ +function ConvertTo-UpdateResultOutcome { + param( + [object]$Plan, + [object]$Installed, + [string]$Outcome, + [string]$VerificationStatus + ) + + [ordered]@{ + InstalledVersion = $Installed.Version + TargetVersion = $Plan.TargetVersion + InstalledPath = $Installed.Path + VerificationStatus = $VerificationStatus + RestartRequired = (Test-RestartRequired $Plan $Outcome) + DependencySummary = @($Plan.DependencySummary) + RecoveryStatus = 'NotRequired' + RecoveryGuidance = '' + } +} diff --git a/src/private/installation/Get-NativeJournalToken.ps1 b/src/private/installation/Get-NativeJournalToken.ps1 new file mode 100644 index 0000000..e796ae7 --- /dev/null +++ b/src/private/installation/Get-NativeJournalToken.ps1 @@ -0,0 +1,18 @@ +function Get-NativeJournalToken { + param( + [object]$Operation, + [object]$Journal + ) + + if (-not $Journal) { + $Journal = Read-RecoveryJournal $Operation + } + if (-not $Journal -or -not $Journal.PSObject.Properties['TransactionId']) { + Write-PMUError 'PMU.PlanStale' 'A native handoff requires its original transaction instance.' + } + [pscustomobject]@{ + TransactionId = $Journal.TransactionId + State = $Journal.State + SHA256 = (Get-FileHash -LiteralPath $Operation.JournalPath -Algorithm SHA256).Hash + } +} diff --git a/src/private/installation/Get-NativeSelectedSnapshot.ps1 b/src/private/installation/Get-NativeSelectedSnapshot.ps1 new file mode 100644 index 0000000..df3d669 --- /dev/null +++ b/src/private/installation/Get-NativeSelectedSnapshot.ps1 @@ -0,0 +1,18 @@ +function Get-NativeSelectedSnapshot { + param( + [object]$Operation, + [bool]$AlreadyInstalled + ) + + $path = Split-Path -Parent $Operation.Plan.InstalledManifestPath + if ($AlreadyInstalled) { + $path = $Operation.Plan.TargetInstalledPath + } + $source = Get-NativeInstalledSource $Operation $path + [pscustomobject]@{ + Path = $path + Source = $source + Version = (Get-ManifestIdentity (Join-Path $path "$($Operation.Plan.ModuleName).psd1")).Version.FullVersion + Tree = @(Get-FileTreeInventory $path) + } +} diff --git a/src/private/installation/Get-ResultInstallationState.ps1 b/src/private/installation/Get-ResultInstallationState.ps1 index 9659efe..59088bf 100644 --- a/src/private/installation/Get-ResultInstallationState.ps1 +++ b/src/private/installation/Get-ResultInstallationState.ps1 @@ -1,8 +1,18 @@ function Get-ResultInstallationState { - param([object]$Plan, [string]$VerificationStatus) + param( + [object]$Plan, + [string]$VerificationStatus + ) + if ($VerificationStatus -ne 'Verified') { - return @{ Version = $Plan.InstalledVersion; Path = Split-Path -Parent $Plan.InstalledManifestPath } + return @{ + Version = $Plan.InstalledVersion + Path = Split-Path -Parent $Plan.InstalledManifestPath + } } $identity = Get-ManifestIdentity (Join-Path $Plan.TargetInstalledPath "$($Plan.ModuleName).psd1") - @{ Version = $identity.Version.FullVersion; Path = $Plan.TargetInstalledPath } + @{ + Version = $identity.Version.FullVersion + Path = $Plan.TargetInstalledPath + } } diff --git a/src/private/installation/Initialize-InstallationRequest.ps1 b/src/private/installation/Initialize-InstallationRequest.ps1 index 0880e12..a888283 100644 --- a/src/private/installation/Initialize-InstallationRequest.ps1 +++ b/src/private/installation/Initialize-InstallationRequest.ps1 @@ -1,9 +1,16 @@ function Initialize-InstallationRequest { - param([object]$Update, [pscredential]$Credential, [hashtable]$Consent) + param( + [object]$Update, + [pscredential]$Credential, + [hashtable]$Consent + ) + $record = Test-UpdatePlan $Update $operation = ConvertTo-InstallationOperation $Update $record $Consent $Credential Assert-PlanEnvironment $operation - if ($Update.RepositoryName) { Assert-ExactTarget $operation } + if ($Update.RepositoryName) { + Assert-ExactTarget $operation + } $journal = Read-RecoveryJournal $operation if (-not $journal -or $journal.State -in @('Completed', 'RecoveryCompleted')) { $null = Test-InstalledPlanState $operation diff --git a/src/private/installation/Initialize-Replacement.ps1 b/src/private/installation/Initialize-Replacement.ps1 index 5073d5a..b7530dd 100644 --- a/src/private/installation/Initialize-Replacement.ps1 +++ b/src/private/installation/Initialize-Replacement.ps1 @@ -1,12 +1,20 @@ function Initialize-Replacement { - param([object]$Operation, [object[]]$Tree) + param( + [object]$Operation, + [object[]]$Tree + ) + $path = $Operation.Plan.TargetInstalledPath $Operation.Reinstall = Test-Path -LiteralPath $path if ($Operation.Reinstall) { $identity = Get-ManifestIdentity (Join-Path $path "$($Operation.Plan.ModuleName).psd1") - if ($identity.Guid -ne $Operation.Plan.ModuleGuid) { Write-PMUError 'PMU.GuidMismatch' 'Refusing to replace a different GUID.' } + if ($identity.Guid -ne $Operation.Plan.ModuleGuid) { + Write-PMUError 'PMU.GuidMismatch' 'Refusing to replace a different GUID.' + } } $Operation.Journal = ConvertTo-RecoveryJournal $Operation $Tree Write-RecoveryJournalState $Operation.Journal 'Preparation' - if ($Operation.Reinstall) { Copy-InstallationBackup $Operation } + if ($Operation.Reinstall) { + Copy-InstallationBackup $Operation + } } diff --git a/src/private/installation/Invoke-LockedInstallation.ps1 b/src/private/installation/Invoke-LockedInstallation.ps1 index 0cec1d3..4dd8ad8 100644 --- a/src/private/installation/Invoke-LockedInstallation.ps1 +++ b/src/private/installation/Invoke-LockedInstallation.ps1 @@ -6,6 +6,6 @@ function Invoke-LockedInstallation { Clear-CompletedOperation $Operation Assert-ExactTarget $Operation Assert-InstallationConsent $Operation - $tree = @(Initialize-StagedPackage $Operation) + $tree = @(Invoke-NativeHandoff $Operation $alreadyInstalled) Complete-VerifiedInstallation $Operation $tree $alreadyInstalled } diff --git a/src/private/installation/Invoke-NativeHandoff.ps1 b/src/private/installation/Invoke-NativeHandoff.ps1 new file mode 100644 index 0000000..6cf5b06 --- /dev/null +++ b/src/private/installation/Invoke-NativeHandoff.ps1 @@ -0,0 +1,17 @@ +function Invoke-NativeHandoff { + param( + [object]$Operation, + [bool]$AlreadyInstalled + ) + + Assert-NativeParentLease $Operation + if (-not $AlreadyInstalled -and -not $Operation.Plan.UpdateAvailable) { + Write-PMUError 'PMU.PlanStale' 'This plan does not authorize a newer target installation.' + } + $snapshot = Get-NativeSelectedSnapshot $Operation $AlreadyInstalled + Initialize-Replacement $Operation @() + $context = ConvertTo-NativeWorkerContext $Operation $snapshot $AlreadyInstalled + $response = Invoke-NativeWorkerProcess $Operation $context + Assert-NativeWorkerResult $Operation $context $response + @($Operation.Journal.StagedTree) +} diff --git a/src/private/installation/Invoke-NativeWorker.ps1 b/src/private/installation/Invoke-NativeWorker.ps1 new file mode 100644 index 0000000..93aeaeb --- /dev/null +++ b/src/private/installation/Invoke-NativeWorker.ps1 @@ -0,0 +1,11 @@ +function Invoke-NativeWorker { + param([object]$Context) + try { + Initialize-NativeProvider $Context.Operation + Write-NativeWorkerMessage ([pscustomobject]@{ Phase = 'READY' }) + Assert-NativeWorkerGo ([Console]::In) + Invoke-NativeWorkerLease $Context + } catch { + Write-NativeWorkerMessage (ConvertTo-NativeWorkerResult $null $_) + } +} diff --git a/src/private/installation/Invoke-NativeWorkerExchange.ps1 b/src/private/installation/Invoke-NativeWorkerExchange.ps1 new file mode 100644 index 0000000..b1c44bb --- /dev/null +++ b/src/private/installation/Invoke-NativeWorkerExchange.ps1 @@ -0,0 +1,16 @@ +function Invoke-NativeWorkerExchange { + param( + [object]$Operation, + [object]$Worker + ) + + $message = Read-NativeWorkerMessage $Worker.Process.StandardOutput + if ($message.Phase -eq 'RESULT') { + return $message + } + if ($message.Phase -cne 'READY') { + Write-PMUError 'PMU.PlanStale' 'The native worker did not enter its nonmutating ready phase.' + } + Send-NativeWorkerGo $Operation $Worker + Read-NativeWorkerMessage $Worker.Process.StandardOutput +} diff --git a/src/private/installation/Invoke-NativeWorkerLease.ps1 b/src/private/installation/Invoke-NativeWorkerLease.ps1 new file mode 100644 index 0000000..ce0ccf7 --- /dev/null +++ b/src/private/installation/Invoke-NativeWorkerLease.ps1 @@ -0,0 +1,13 @@ +function Invoke-NativeWorkerLease { + param([object]$Context) + $operation = $Context.Operation + $lease = Enter-InstallationLock $operation.LockPath + try { + Assert-NativeWorkerParent $Context + $operation.Journal = Assert-NativeHandoffJournal $operation $Context.Token + Assert-NativeSelectedInstallation $Context + Write-NativeWorkerMessage (Invoke-OwnedNativeUpdate $Context) + } finally { + $lease.Dispose() + } +} diff --git a/src/private/installation/Invoke-NativeWorkerProcess.ps1 b/src/private/installation/Invoke-NativeWorkerProcess.ps1 new file mode 100644 index 0000000..e03746a --- /dev/null +++ b/src/private/installation/Invoke-NativeWorkerProcess.ps1 @@ -0,0 +1,18 @@ +function Invoke-NativeWorkerProcess { + param( + [object]$Operation, + [object]$Context + ) + + $worker = Invoke-NativeWorkerStart $Operation $Context + $response = $null + try { + $response = Invoke-NativeWorkerExchange $Operation $worker + } finally { + Complete-NativeWorkerLease $Operation $worker + } + if ($worker.ExitCode -ne 0) { + Write-PMUError 'PMU.RecoveryRequired' 'The native worker exited unsuccessfully. Pending state was preserved.' + } + $response +} diff --git a/src/private/installation/Invoke-OwnedNativeUpdate.ps1 b/src/private/installation/Invoke-OwnedNativeUpdate.ps1 new file mode 100644 index 0000000..4a36552 --- /dev/null +++ b/src/private/installation/Invoke-OwnedNativeUpdate.ps1 @@ -0,0 +1,17 @@ +function Invoke-OwnedNativeUpdate { + param([object]$Context) + $operation = $Context.Operation + try { + $tree = @(Initialize-StagedPackage $operation) + Assert-NativeSelectedInstallation $Context + $operation.Journal.StagedTree = $tree + if ($Context.AlreadyInstalled) { + Confirm-NativeExistingTarget $operation $tree + } else { + Invoke-ProviderInstallation $operation $tree + } + ConvertTo-NativeWorkerResult $operation $null + } catch { + ConvertTo-NativeWorkerResult $operation $_ + } +} diff --git a/src/private/installation/Invoke-ProviderInstallation.ps1 b/src/private/installation/Invoke-ProviderInstallation.ps1 index 7a51f1c..41e6b67 100644 --- a/src/private/installation/Invoke-ProviderInstallation.ps1 +++ b/src/private/installation/Invoke-ProviderInstallation.ps1 @@ -1,14 +1,18 @@ function Invoke-ProviderInstallation { - param([object]$Operation, [object[]]$StagedTree) + param( + [object]$Operation, + [object[]]$StagedTree + ) + Write-RecoveryJournalState $Operation.Journal 'ReplacementInProgress' try { Write-InstallationPhase $Operation.Plan 'Replacing' - Assert-RepositoryBinding $Operation.Plan $Operation.RepositoryName + Assert-NativeRepositoryBinding $Operation Install-ProviderResource $Operation Write-RecoveryJournalState $Operation.Journal 'Verification' Write-InstallationPhase $Operation.Plan 'Verifying' Assert-InstalledPackage $Operation $StagedTree - Write-RecoveryJournalState $Operation.Journal 'Completed' + } catch { + Resolve-InstallationFailure $Operation $_ } - catch { Resolve-InstallationFailure $Operation $_ } } diff --git a/src/private/installation/Invoke-RepositoryInstallation.ps1 b/src/private/installation/Invoke-RepositoryInstallation.ps1 index d9a2a36..43ed910 100644 --- a/src/private/installation/Invoke-RepositoryInstallation.ps1 +++ b/src/private/installation/Invoke-RepositoryInstallation.ps1 @@ -1,11 +1,15 @@ function Invoke-RepositoryInstallation { param([object]$Operation) - $plan = $Operation.Plan - $source = [pscustomobject]@{ Name = $plan.RepositoryName; Uri = $plan.RepositoryUri; ApiVersion = $plan.RepositoryApiVersion; Trusted = $plan.RepositoryTrusted } + $source = ConvertTo-PlanRepositorySource $Operation.Plan $lease = Open-RepositoryTransport $source $Operation.RepositoryName = $lease.Name $failure = $null - try { Invoke-ScopedInstallation $Operation } - catch { $failure = $_; throw } - finally { Close-RepositoryTransport $lease $failure } + try { + Invoke-ScopedInstallation $Operation + } catch { + $failure = $_ + throw + } finally { + Close-RepositoryTransport $lease $failure + } } diff --git a/src/private/installation/Invoke-ScopedInstallation.ps1 b/src/private/installation/Invoke-ScopedInstallation.ps1 index aff93ae..ef2f4a1 100644 --- a/src/private/installation/Invoke-ScopedInstallation.ps1 +++ b/src/private/installation/Invoke-ScopedInstallation.ps1 @@ -2,7 +2,13 @@ function Invoke-ScopedInstallation { param([object]$Operation) Initialize-ControlledDirectory $Operation.DataRoot Initialize-ControlledDirectory $Operation.WorkRoot - $lock = Enter-InstallationLock $Operation.LockPath - try { Invoke-LockedInstallation $Operation } - finally { $lock.Dispose() } + $Operation.Lock = Enter-InstallationLock $Operation.LockPath + try { + Invoke-LockedInstallation $Operation + } finally { + if ($Operation.Lock) { + $Operation.Lock.Dispose() + $Operation.Lock = $null + } + } } diff --git a/src/private/installation/Invoke-UpdateInstallation.ps1 b/src/private/installation/Invoke-UpdateInstallation.ps1 index 85c0767..55e181f 100644 --- a/src/private/installation/Invoke-UpdateInstallation.ps1 +++ b/src/private/installation/Invoke-UpdateInstallation.ps1 @@ -2,7 +2,11 @@ function Invoke-UpdateInstallation { param([object]$Operation) Assert-InstallationConsent $Operation Write-InstallationPhase $Operation.Plan 'Checking' - try { Invoke-RepositoryInstallation $Operation } - catch [UnauthorizedAccessException] { Write-PMUError 'PMU.PermissionDenied' 'The selected scope is not writable. PMU does not elevate or change scope.' } - finally { Write-InstallationPhase $Operation.Plan 'Completed' } + try { + Invoke-RepositoryInstallation $Operation + } catch [UnauthorizedAccessException] { + Write-PMUError 'PMU.PermissionDenied' 'The selected scope is not writable. PMU does not elevate or change scope.' + } finally { + Write-InstallationPhase $Operation.Plan 'Completed' + } } diff --git a/src/private/installation/Read-NativeWorkerMessage.ps1 b/src/private/installation/Read-NativeWorkerMessage.ps1 new file mode 100644 index 0000000..7e66f10 --- /dev/null +++ b/src/private/installation/Read-NativeWorkerMessage.ps1 @@ -0,0 +1,9 @@ +function Read-NativeWorkerMessage { + param([IO.TextReader]$Reader) + while ($null -ne ($line = $Reader.ReadLine())) { + if ($line.StartsWith('PMU_NATIVE:', [StringComparison]::Ordinal)) { + return $line.Substring(11) | ConvertFrom-Json -ErrorAction Stop + } + } + Write-PMUError 'PMU.RecoveryRequired' 'The native worker stopped without a complete protocol response. Pending state was preserved.' +} diff --git a/src/private/installation/Resolve-InstallationFailure.ps1 b/src/private/installation/Resolve-InstallationFailure.ps1 index e9f5cdc..3134866 100644 --- a/src/private/installation/Resolve-InstallationFailure.ps1 +++ b/src/private/installation/Resolve-InstallationFailure.ps1 @@ -1,11 +1,18 @@ function Resolve-InstallationFailure { - param([object]$Operation, [System.Management.Automation.ErrorRecord]$Failure) - try { Restore-InstallationBackup $Operation.Journal $Operation } - catch { + param( + [object]$Operation, + [System.Management.Automation.ErrorRecord]$Failure + ) + + try { + Restore-InstallationBackup $Operation.Journal $Operation + } catch { $Operation.Journal.RecoveryStatus = 'Failed' Write-RecoveryJournalState $Operation.Journal 'RecoveryRequired' Write-PMUError 'PMU.RollbackFailed' 'Installation and rollback both failed. Recoverable evidence was preserved.' 'Do not delete the PMU journal or backup; inspect the selected installation before obtaining a fresh plan.' } - if ($Failure.FullyQualifiedErrorId -like 'PMU.*') { throw $Failure } + if ($Failure.FullyQualifiedErrorId -like 'PMU.*') { + throw $Failure + } Write-ProviderError $Failure 'PMU.InstallationFailed' } diff --git a/src/private/installation/Restore-NativeParentLease.ps1 b/src/private/installation/Restore-NativeParentLease.ps1 new file mode 100644 index 0000000..1ac81a9 --- /dev/null +++ b/src/private/installation/Restore-NativeParentLease.ps1 @@ -0,0 +1,9 @@ +function Restore-NativeParentLease { + param([object]$Operation) + if ($Operation.Lock -and $Operation.Lock.CanRead) { + Assert-NativeParentLease $Operation + return + } + $Operation.Lock = $null + $Operation.Lock = Enter-InstallationLock $Operation.LockPath +} diff --git a/src/private/installation/Send-NativeWorkerGo.ps1 b/src/private/installation/Send-NativeWorkerGo.ps1 new file mode 100644 index 0000000..b9e9f15 --- /dev/null +++ b/src/private/installation/Send-NativeWorkerGo.ps1 @@ -0,0 +1,12 @@ +function Send-NativeWorkerGo { + param( + [object]$Operation, + [object]$Worker + ) + + Assert-NativeParentLease $Operation + $Operation.Lock.Dispose() + $Operation.Lock = $null + $Worker.Process.StandardInput.WriteLine('GO') + $Worker.Process.StandardInput.Close() +} diff --git a/src/private/installation/Test-InstalledPlanState.ps1 b/src/private/installation/Test-InstalledPlanState.ps1 index 02ab340..cdea450 100644 --- a/src/private/installation/Test-InstalledPlanState.ps1 +++ b/src/private/installation/Test-InstalledPlanState.ps1 @@ -4,7 +4,9 @@ function Test-InstalledPlanState { $current = @(Get-InstalledModuleIdentityList $plan.ModuleName $plan.Scope) Assert-SelectedInstallation $current $plan.ModuleGuid $target = @($current | Where-Object FullVersion -CEQ $plan.TargetVersion) - if ($target.Count -eq 1) { return $true } + if ($target.Count -eq 1) { + return $true + } $original = @($plan.ObservedInstalledVersions | Where-Object Scope -EQ $plan.Scope) if ((ConvertTo-Json @($current) -Depth 5 -Compress) -cne (ConvertTo-Json @($original) -Depth 5 -Compress)) { Write-PMUError 'PMU.InstalledStateChanged' 'The selected-scope installation changed after planning.' diff --git a/src/private/installation/Test-RestartRequired.ps1 b/src/private/installation/Test-RestartRequired.ps1 index 60f3207..6b53d2f 100644 --- a/src/private/installation/Test-RestartRequired.ps1 +++ b/src/private/installation/Test-RestartRequired.ps1 @@ -1,10 +1,18 @@ function Test-RestartRequired { - param([object]$Plan, [string]$Outcome) + param( + [object]$Plan, + [string]$Outcome + ) + $loaded = @(Get-Module -Name $Plan.ModuleName | Where-Object Guid -EQ $Plan.ModuleGuid) - if (-not $loaded.Count) { return $false } + if (-not $loaded.Count) { + return $false + } $target = [IO.Path]::GetFullPath($Plan.TargetInstalledPath) $replacedLoaded = @($loaded | Where-Object { [IO.Path]::GetFullPath($_.ModuleBase) -eq $target }).Count -gt 0 - if ($Outcome -eq 'Installed' -and $replacedLoaded) { return $true } + if ($Outcome -eq 'Installed' -and $replacedLoaded) { + return $true + } $behindTarget = @($loaded | Where-Object { (Compare-CompleteModuleVersion $Plan.TargetVersion (Get-ActiveModuleVersion $_).FullVersion) -gt 0 }) $behindTarget.Count -gt 0 } diff --git a/src/private/installation/Wait-NativeWorkerStop.ps1 b/src/private/installation/Wait-NativeWorkerStop.ps1 new file mode 100644 index 0000000..dfa839a --- /dev/null +++ b/src/private/installation/Wait-NativeWorkerStop.ps1 @@ -0,0 +1,10 @@ +function Wait-NativeWorkerStop { + param([object]$Worker) + $Worker.Process.StandardInput.Close() + $output = $Worker.Process.StandardOutput.ReadToEndAsync() + # Never release exclusion by killing an active writer on a parent timeout. + $Worker.Process.WaitForExit() + $Worker.ExitCode = $Worker.Process.ExitCode + $null = $output.GetAwaiter().GetResult() + $null = $Worker.Errors.GetAwaiter().GetResult() +} diff --git a/src/private/installation/Write-InstallationPhase.ps1 b/src/private/installation/Write-InstallationPhase.ps1 index 2e0ddff..7052de6 100644 --- a/src/private/installation/Write-InstallationPhase.ps1 +++ b/src/private/installation/Write-InstallationPhase.ps1 @@ -1,7 +1,15 @@ function Write-InstallationPhase { - param([object]$Plan, [string]$Phase) - if ([Console]::IsOutputRedirected) { return } - if ([Environment]::GetCommandLineArgs() -contains '-NonInteractive') { return } + param( + [object]$Plan, + [string]$Phase + ) + + if ([Console]::IsOutputRedirected) { + return + } + if ([Environment]::GetCommandLineArgs() -contains '-NonInteractive') { + return + } $progressId = 17330 # Stable native progress activity for the single-module workflow. $activity = "Updating $($Plan.ModuleName)" if ($Phase -eq 'Completed') { diff --git a/src/private/installation/Write-NativeWorkerError.ps1 b/src/private/installation/Write-NativeWorkerError.ps1 new file mode 100644 index 0000000..64cb7c2 --- /dev/null +++ b/src/private/installation/Write-NativeWorkerError.ps1 @@ -0,0 +1,8 @@ +function Write-NativeWorkerError { + param([object]$Result) + $id = 'PMU.InstallationFailed' + if ($Result.ErrorId -match '^PMU\.[A-Za-z]+$') { + $id = $Result.ErrorId + } + Write-PMUError $id 'The native update failed. Recoverable evidence was preserved.' 'Do not delete pending PMU transaction data. Correct the reported condition, then obtain a fresh plan to recover or retry.' +} diff --git a/src/private/installation/Write-NativeWorkerMessage.ps1 b/src/private/installation/Write-NativeWorkerMessage.ps1 new file mode 100644 index 0000000..200cddf --- /dev/null +++ b/src/private/installation/Write-NativeWorkerMessage.ps1 @@ -0,0 +1,5 @@ +function Write-NativeWorkerMessage { + param([object]$Message) + [Console]::Out.WriteLine('PMU_NATIVE:' + (ConvertTo-Json $Message -Depth 6 -Compress)) + [Console]::Out.Flush() +} diff --git a/src/private/locking/Assert-UnixDirectoryOwner.ps1 b/src/private/locking/Assert-UnixDirectoryOwner.ps1 index 8030621..4869d30 100644 --- a/src/private/locking/Assert-UnixDirectoryOwner.ps1 +++ b/src/private/locking/Assert-UnixDirectoryOwner.ps1 @@ -2,7 +2,9 @@ function Assert-UnixDirectoryOwner { param([string]$Path) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop $owner = $item.PSObject.Properties['User'] - if (-not $owner) { Write-PMUError 'PMU.PermissionDenied' 'The data-directory owner cannot be verified on this platform.' } + if (-not $owner) { + Write-PMUError 'PMU.PermissionDenied' 'The data-directory owner cannot be verified on this platform.' + } if ($owner.Value -cnotin @([Environment]::UserName, 'root')) { Write-PMUError 'PMU.PermissionDenied' 'The PMU data directory is owned by an unrelated account.' } diff --git a/src/private/locking/Assert-WindowsAccessRule.ps1 b/src/private/locking/Assert-WindowsAccessRule.ps1 index 98ee5b8..c8ddfea 100644 --- a/src/private/locking/Assert-WindowsAccessRule.ps1 +++ b/src/private/locking/Assert-WindowsAccessRule.ps1 @@ -1,7 +1,15 @@ function Assert-WindowsAccessRule { - param([object]$Rule, [string[]]$Trusted) - if (-not $Rule.Allowed) { return } - if (-not $Rule.CanWrite) { return } + param( + [object]$Rule, + [string[]]$Trusted + ) + + if (-not $Rule.Allowed) { + return + } + if (-not $Rule.CanWrite) { + return + } if ($Rule.Identity -notin $Trusted) { Write-PMUError 'PMU.PermissionDenied' 'The PMU data directory grants write access to an unrelated account or group.' } diff --git a/src/private/locking/Assert-WindowsDirectoryDescriptor.ps1 b/src/private/locking/Assert-WindowsDirectoryDescriptor.ps1 index 69386b8..b6d2e1a 100644 --- a/src/private/locking/Assert-WindowsDirectoryDescriptor.ps1 +++ b/src/private/locking/Assert-WindowsDirectoryDescriptor.ps1 @@ -1,5 +1,9 @@ function Assert-WindowsDirectoryDescriptor { param([object]$Descriptor) - if ($Descriptor.Owner -notin $Descriptor.Trusted) { Write-PMUError 'PMU.PermissionDenied' 'The PMU data-directory owner is not trusted.' } - foreach ($rule in $Descriptor.Access) { Assert-WindowsAccessRule $rule $Descriptor.Trusted } + if ($Descriptor.Owner -notin $Descriptor.Trusted) { + Write-PMUError 'PMU.PermissionDenied' 'The PMU data-directory owner is not trusted.' + } + foreach ($rule in $Descriptor.Access) { + Assert-WindowsAccessRule $rule $Descriptor.Trusted + } } diff --git a/src/private/locking/ConvertFrom-LinuxProcessStat.ps1 b/src/private/locking/ConvertFrom-LinuxProcessStat.ps1 new file mode 100644 index 0000000..32e880a --- /dev/null +++ b/src/private/locking/ConvertFrom-LinuxProcessStat.ps1 @@ -0,0 +1,8 @@ +function ConvertFrom-LinuxProcessStat { + param([string]$Stat) + $fields = [regex]::Match($Stat, '(?s)^[0-9]+ \(.*\) (.+)$').Groups[1].Value -split ' ' + if ($fields.Count -lt 20) { + throw [IO.InvalidDataException]::new('Linux process start metadata is incomplete.') + } + [long]::Parse($fields[19], [Globalization.CultureInfo]::InvariantCulture) +} diff --git a/src/private/locking/ConvertTo-LinuxProcessStartIdentity.ps1 b/src/private/locking/ConvertTo-LinuxProcessStartIdentity.ps1 new file mode 100644 index 0000000..a5dbd42 --- /dev/null +++ b/src/private/locking/ConvertTo-LinuxProcessStartIdentity.ps1 @@ -0,0 +1,15 @@ +function ConvertTo-LinuxProcessStartIdentity { + param([string]$Stat, [string]$BootStat, [string]$ClockTicksPerSecond) + $started = ConvertFrom-LinuxProcessStat $Stat + $boot = [regex]::Match($BootStat, '(?m)^btime ([0-9]+)\r?$') + if (-not $boot.Success -or $ClockTicksPerSecond -notmatch '^[1-9][0-9]*$') { + throw [IO.InvalidDataException]::new('Linux process clock metadata is invalid.') + } + $frequency = [long]::Parse($ClockTicksPerSecond, [Globalization.CultureInfo]::InvariantCulture) + if ([TimeSpan]::TicksPerSecond % $frequency) { + throw [IO.InvalidDataException]::new('Linux process clock ticks cannot be represented exactly.') + } + $unitTicks = [long]([TimeSpan]::TicksPerSecond / $frequency) + # Prefix 1 distinguishes kernel-derived Unix-epoch ticks from legacy .NET UTC receipts. + '1' + ([long]$boot.Groups[1].Value * [TimeSpan]::TicksPerSecond + $started * $unitTicks).ToString('D17', [Globalization.CultureInfo]::InvariantCulture) +} diff --git a/src/private/locking/Enter-InstallationLock.ps1 b/src/private/locking/Enter-InstallationLock.ps1 index 51bafec..fd9faec 100644 --- a/src/private/locking/Enter-InstallationLock.ps1 +++ b/src/private/locking/Enter-InstallationLock.ps1 @@ -1,11 +1,19 @@ function Enter-InstallationLock { - param([string]$Path, [int]$TimeoutMilliseconds = 5000) + param( + [string]$Path, + [int]$TimeoutMilliseconds = 5000 + ) + $null = Assert-SafePath $Path $timer = [Diagnostics.Stopwatch]::StartNew() do { - try { return [IO.File]::Open($Path, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } - catch [UnauthorizedAccessException] { Write-PMUError 'PMU.PermissionDenied' 'Cannot open the scope-specific PMU lock.' } - catch [IO.IOException] { Start-Sleep -Milliseconds 50 } + try { + return [IO.File]::Open($Path, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch [UnauthorizedAccessException] { + Write-PMUError 'PMU.PermissionDenied' 'Cannot open the scope-specific PMU lock.' + } catch [IO.IOException] { + Start-Sleep -Milliseconds 50 + } } while ($timer.ElapsedMilliseconds -lt $TimeoutMilliseconds) Write-PMUError 'PMU.LockTimeout' 'Another process holds the module/scope/root lock. Retry after it completes.' } diff --git a/src/private/locking/Enter-RepositoryRegistryLock.ps1 b/src/private/locking/Enter-RepositoryRegistryLock.ps1 index 496cc1d..ed3d6fb 100644 --- a/src/private/locking/Enter-RepositoryRegistryLock.ps1 +++ b/src/private/locking/Enter-RepositoryRegistryLock.ps1 @@ -1,5 +1,9 @@ function Enter-RepositoryRegistryLock { - param([string]$Root, [int]$TimeoutMilliseconds = 5000) + param( + [string]$Root, + [int]$TimeoutMilliseconds = 5000 + ) + Initialize-ControlledDirectory $Root Enter-InstallationLock (Join-Path $Root 'registry.lock') $TimeoutMilliseconds } diff --git a/src/private/locking/Get-InstallationLockIdentity.ps1 b/src/private/locking/Get-InstallationLockIdentity.ps1 index 381e84f..52e4dc4 100644 --- a/src/private/locking/Get-InstallationLockIdentity.ps1 +++ b/src/private/locking/Get-InstallationLockIdentity.ps1 @@ -1,7 +1,9 @@ function Get-InstallationLockIdentity { param([object]$Plan) $identity = "$($Plan.ModuleName.ToLowerInvariant())|$($Plan.Scope)|$($Plan.DestinationRoot)" - if ($IsWindows -or $IsMacOS) { $identity = $identity.ToLowerInvariant() } + if ($IsWindows -or $IsMacOS) { + $identity = $identity.ToLowerInvariant() + } $hash = [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($identity)) [Convert]::ToHexString($hash).ToLowerInvariant() } diff --git a/src/private/locking/Get-LinuxRepositoryProcessStart.ps1 b/src/private/locking/Get-LinuxRepositoryProcessStart.ps1 new file mode 100644 index 0000000..71958e6 --- /dev/null +++ b/src/private/locking/Get-LinuxRepositoryProcessStart.ps1 @@ -0,0 +1,13 @@ +function Get-LinuxRepositoryProcessStart { + param([int]$ProcessId) + try { + $stat = [IO.File]::ReadAllText("/proc/$ProcessId/stat") + } catch [IO.FileNotFoundException], [IO.DirectoryNotFoundException] { + return $null + } + $frequency = & '/usr/bin/getconf' CLK_TCK + if ($LASTEXITCODE -ne 0) { + throw [IO.InvalidDataException]::new('Cannot read the Linux process clock frequency.') + } + ConvertTo-LinuxProcessStartIdentity $stat ([IO.File]::ReadAllText('/proc/stat')) $frequency +} diff --git a/src/private/locking/Get-PlatformRepositoryProcessStart.ps1 b/src/private/locking/Get-PlatformRepositoryProcessStart.ps1 new file mode 100644 index 0000000..158eafb --- /dev/null +++ b/src/private/locking/Get-PlatformRepositoryProcessStart.ps1 @@ -0,0 +1,12 @@ +function Get-PlatformRepositoryProcessStart { + param([int]$ProcessId) + if ($IsLinux) { + return Get-LinuxRepositoryProcessStart $ProcessId + } + $process = [Diagnostics.Process]::GetProcessById($ProcessId) + try { + "$($process.StartTime.ToUniversalTime().Ticks)" + } finally { + $process.Dispose() + } +} diff --git a/src/private/locking/Get-RepositoryProcessStart.ps1 b/src/private/locking/Get-RepositoryProcessStart.ps1 index e31db17..4a8893e 100644 --- a/src/private/locking/Get-RepositoryProcessStart.ps1 +++ b/src/private/locking/Get-RepositoryProcessStart.ps1 @@ -1,8 +1,10 @@ function Get-RepositoryProcessStart { param([int]$ProcessId) - try { $process = [Diagnostics.Process]::GetProcessById($ProcessId) } - catch [ArgumentException] { return $null } - try { "$($process.StartTime.ToUniversalTime().Ticks)" } - catch { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Cannot verify the repository receipt owner process.' } - finally { $process.Dispose() } + try { + Get-PlatformRepositoryProcessStart $ProcessId + } catch [ArgumentException] { + return $null + } catch { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Cannot verify the repository receipt owner process.' + } } diff --git a/src/private/locking/Get-WindowsDirectoryDescriptor.ps1 b/src/private/locking/Get-WindowsDirectoryDescriptor.ps1 index 8f7ea67..a9fb957 100644 --- a/src/private/locking/Get-WindowsDirectoryDescriptor.ps1 +++ b/src/private/locking/Get-WindowsDirectoryDescriptor.ps1 @@ -1,8 +1,11 @@ function Get-WindowsDirectoryDescriptor { param([string]$Path) $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - try { $trusted = @($identity.User.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-0') } - finally { $identity.Dispose() } + try { + $trusted = @($identity.User.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-3-0') + } finally { + $identity.Dispose() + } $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop $rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]) [pscustomobject]@{ diff --git a/src/private/locking/Initialize-ControlledDirectory.ps1 b/src/private/locking/Initialize-ControlledDirectory.ps1 index 623b510..833835d 100644 --- a/src/private/locking/Initialize-ControlledDirectory.ps1 +++ b/src/private/locking/Initialize-ControlledDirectory.ps1 @@ -1,7 +1,10 @@ function Initialize-ControlledDirectory { param([string]$Path) $safe = Assert-SafePath $Path - if ($IsWindows) { Initialize-WindowsControlledDirectory $safe; return } + if ($IsWindows) { + Initialize-WindowsControlledDirectory $safe + return + } $mode = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute $null = [IO.Directory]::CreateDirectory($safe, $mode) Assert-UnixDirectoryOwner $safe diff --git a/src/private/locking/Resolve-PMUDataRoot.ps1 b/src/private/locking/Resolve-PMUDataRoot.ps1 index 960ecc2..dc286b0 100644 --- a/src/private/locking/Resolve-PMUDataRoot.ps1 +++ b/src/private/locking/Resolve-PMUDataRoot.ps1 @@ -2,10 +2,16 @@ function Resolve-PMUDataRoot { param([string]$Scope) if ($IsWindows) { $folder = 'LocalApplicationData' - if ($Scope -eq 'AllUsers') { $folder = 'CommonApplicationData' } + if ($Scope -eq 'AllUsers') { + $folder = 'CommonApplicationData' + } return Assert-SafePath (Join-Path ([Environment]::GetFolderPath($folder)) 'PSModuleUpdate') } - if ($Scope -eq 'CurrentUser') { return Assert-SafePath (Join-Path $HOME '.local/state/PSModuleUpdate') } - if ($IsMacOS) { return Assert-SafePath '/Library/Application Support/PSModuleUpdate' } + if ($Scope -eq 'CurrentUser') { + return Assert-SafePath (Join-Path $HOME '.local/state/PSModuleUpdate') + } + if ($IsMacOS) { + return Assert-SafePath '/Library/Application Support/PSModuleUpdate' + } Assert-SafePath '/var/lib/PSModuleUpdate' } diff --git a/src/private/planning/Assert-PolicyValue.ps1 b/src/private/planning/Assert-PolicyValue.ps1 index 28a366e..a5ca623 100644 --- a/src/private/planning/Assert-PolicyValue.ps1 +++ b/src/private/planning/Assert-PolicyValue.ps1 @@ -1,6 +1,18 @@ function Assert-PolicyValue { - param([string]$Name, [object]$Value, [type]$Type, [string[]]$Allowed = @()) - if ($null -eq $Value) { return } - if ($Value -isnot $Type) { Write-PMUError 'PMU.InvalidRequest' "Policy field $Name has the wrong type." } - if ($Allowed.Count -and $Value -cnotin $Allowed) { Write-PMUError 'PMU.InvalidRequest' "Policy field $Name has an unsupported value." } + param( + [string]$Name, + [object]$Value, + [type]$Type, + [string[]]$Allowed = @() + ) + + if ($null -eq $Value) { + return + } + if ($Value -isnot $Type) { + Write-PMUError 'PMU.InvalidRequest' "Policy field $Name has the wrong type." + } + if ($Allowed.Count -and $Value -cnotin $Allowed) { + Write-PMUError 'PMU.InvalidRequest' "Policy field $Name has an unsupported value." + } } diff --git a/src/private/planning/Assert-UpdatePolicy.ps1 b/src/private/planning/Assert-UpdatePolicy.ps1 index fdc6e24..fc74be4 100644 --- a/src/private/planning/Assert-UpdatePolicy.ps1 +++ b/src/private/planning/Assert-UpdatePolicy.ps1 @@ -1,7 +1,11 @@ function Assert-UpdatePolicy { param([hashtable]$Policy) - if ($null -eq $Policy.Channel) { Write-PMUError 'PMU.InvalidRequest' 'Channel cannot be null.' } - if ($null -eq $Policy.Scope) { Write-PMUError 'PMU.InvalidRequest' 'Scope cannot be null.' } + if ($null -eq $Policy.Channel) { + Write-PMUError 'PMU.InvalidRequest' 'Channel cannot be null.' + } + if ($null -eq $Policy.Scope) { + Write-PMUError 'PMU.InvalidRequest' 'Scope cannot be null.' + } Assert-PolicyValue 'Channel' $Policy.Channel ([string]) @('Stable', 'IncludePrerelease') Assert-PolicyValue 'Scope' $Policy.Scope ([string]) @('CurrentUser', 'AllUsers') Assert-PolicyValue 'ExpectedRepositoryUri' $Policy.ExpectedRepositoryUri ([uri]) diff --git a/src/private/planning/Complete-PlanningContext.ps1 b/src/private/planning/Complete-PlanningContext.ps1 index 3f2fda5..aea64c4 100644 --- a/src/private/planning/Complete-PlanningContext.ps1 +++ b/src/private/planning/Complete-PlanningContext.ps1 @@ -1,10 +1,18 @@ function Complete-PlanningContext { - param([object]$Context, [string]$RepositoryName, [pscredential]$Credential) + param( + [object]$Context, + [string]$RepositoryName, + [pscredential]$Credential + ) + $state = Get-PlanningInstallationState $Context.Module.Name $Context.Policy.Scope $Context.Reference.Guid Assert-ReferenceInstallationLayout $Context.Reference $state.Observed $state.RecoveryJournal - $request = @{ Name = $Context.Module.Name; Repository = $RepositoryName; Prerelease = $Context.Policy.Channel -eq 'IncludePrerelease'; Version = $Context.Policy.VersionRange } - $releases = @(Find-ProviderResource $request $Credential | ForEach-Object { ConvertTo-ProviderRelease $_ }) - $target = Select-UpdateTarget $releases $Context.Policy.Channel - $Context | Add-Member -NotePropertyMembers @{ Observed = $state.Observed; Installed = $state.Installed; InstalledTree = $state.InstalledTree; Target = $target } + $target = Get-PlanningUpdateTarget $Context $RepositoryName $Credential + $Context | Add-Member -NotePropertyMembers @{ + Observed = $state.Observed + Installed = $state.Installed + InstalledTree = $state.InstalledTree + Target = $target + } $Context } diff --git a/src/private/planning/ConvertTo-UpdateDecision.ps1 b/src/private/planning/ConvertTo-UpdateDecision.ps1 new file mode 100644 index 0000000..3b51cc4 --- /dev/null +++ b/src/private/planning/ConvertTo-UpdateDecision.ps1 @@ -0,0 +1,19 @@ +function ConvertTo-UpdateDecision { + param( + [Parameter(Mandatory)] + [ValidateSet('UpToDate', 'UpdateAvailable', 'AlreadyInstalledRestartRequired', IgnoreCase = $false)] + [string]$Disposition + ) + + $reason = switch -CaseSensitive ($Disposition) { + 'UpToDate' { 'No release newer than both actual active and installed versions is available.' } + 'UpdateAvailable' { 'An exact newer release is available.' } + 'AlreadyInstalledRestartRequired' { 'A newer version is already installed in the selected scope.' } + } + @{ + Available = $Disposition -ceq 'UpdateAvailable' + Disposition = $Disposition + Reason = $reason + Restart = $Disposition -ceq 'AlreadyInstalledRestartRequired' + } +} diff --git a/src/private/planning/ConvertTo-UpdatePlan.ps1 b/src/private/planning/ConvertTo-UpdatePlan.ps1 index 6d61673..725c456 100644 --- a/src/private/planning/ConvertTo-UpdatePlan.ps1 +++ b/src/private/planning/ConvertTo-UpdatePlan.ps1 @@ -3,7 +3,9 @@ function ConvertTo-UpdatePlan { $fields = [ordered]@{ PSTypeName = 'PSModuleUpdate.UpdatePlan' } $groups = @((Get-PlanRuntimeField $Context), (Get-PlanInstallationField $Context), (Get-PlanTargetField $Context), (Get-PlanDecisionField $Context)) foreach ($group in $groups) { - foreach ($entry in $group.GetEnumerator()) { $fields[$entry.Key] = $entry.Value } + foreach ($entry in $group.GetEnumerator()) { + $fields[$entry.Key] = $entry.Value + } } [pscustomobject]$fields } diff --git a/src/private/planning/ConvertTo-UpdatePolicy.ps1 b/src/private/planning/ConvertTo-UpdatePolicy.ps1 index 780b71d..2bbbb00 100644 --- a/src/private/planning/ConvertTo-UpdatePolicy.ps1 +++ b/src/private/planning/ConvertTo-UpdatePolicy.ps1 @@ -1,8 +1,15 @@ function ConvertTo-UpdatePolicy { param([hashtable]$Policy = @{}) - $resolved = @{ Channel = 'Stable'; Scope = 'CurrentUser'; ExpectedRepositoryUri = $null; VersionRange = $null } + $resolved = @{ + Channel = 'Stable' + Scope = 'CurrentUser' + ExpectedRepositoryUri = $null + VersionRange = $null + } foreach ($key in $Policy.Keys) { - if (-not $resolved.ContainsKey($key)) { Write-PMUError 'PMU.InvalidRequest' 'Unknown policy field.' } + if (-not $resolved.ContainsKey($key)) { + Write-PMUError 'PMU.InvalidRequest' 'Unknown policy field.' + } $resolved[$key] = $Policy[$key] } Assert-UpdatePolicy $resolved diff --git a/src/private/planning/Get-ActivePlanField.ps1 b/src/private/planning/Get-ActivePlanField.ps1 index 247681f..3b337e3 100644 --- a/src/private/planning/Get-ActivePlanField.ps1 +++ b/src/private/planning/Get-ActivePlanField.ps1 @@ -1,6 +1,16 @@ function Get-ActivePlanField { param([object]$Context) - if (-not $Context.Active) { return @{ Version = $null; Path = $null; Scope = $null } } + if (-not $Context.Active) { + return @{ + Version = $null + Path = $null + Scope = $null + } + } $scope = $Context.Observed | Where-Object ManifestPath -CEQ $Context.Active.ManifestPath | Select-Object -First 1 -ExpandProperty Scope - @{ Version = $Context.Active.Version.FullVersion; Path = $Context.Active.ManifestPath; Scope = $scope } + @{ + Version = $Context.Active.Version.FullVersion + Path = $Context.Active.ManifestPath + Scope = $scope + } } diff --git a/src/private/planning/Get-EligibleRelease.ps1 b/src/private/planning/Get-EligibleRelease.ps1 index f370046..5800035 100644 --- a/src/private/planning/Get-EligibleRelease.ps1 +++ b/src/private/planning/Get-EligibleRelease.ps1 @@ -1,7 +1,13 @@ function Get-EligibleRelease { - param([object[]]$Releases, [string]$Channel) + param( + [object[]]$Releases, + [string]$Channel + ) + foreach ($release in $Releases) { - if ($Channel -eq 'Stable' -and $release.Version.IsPrerelease) { continue } + if ($Channel -eq 'Stable' -and $release.Version.IsPrerelease) { + continue + } $release } } diff --git a/src/private/planning/Get-LivePlanRegistry.ps1 b/src/private/planning/Get-LivePlanRegistry.ps1 index 9102737..03fd1ae 100644 --- a/src/private/planning/Get-LivePlanRegistry.ps1 +++ b/src/private/planning/Get-LivePlanRegistry.ps1 @@ -3,5 +3,5 @@ function Get-LivePlanRegistry { if (-not (Get-Variable -Name PmuLivePlans -Scope Script -ErrorAction SilentlyContinue)) { $script:PmuLivePlans = [System.Runtime.CompilerServices.ConditionalWeakTable[object, object]]::new() } - ,$script:PmuLivePlans + , $script:PmuLivePlans } diff --git a/src/private/planning/Get-PlanningContext.ps1 b/src/private/planning/Get-PlanningContext.ps1 index 361372b..1b7c574 100644 --- a/src/private/planning/Get-PlanningContext.ps1 +++ b/src/private/planning/Get-PlanningContext.ps1 @@ -1,9 +1,20 @@ function Get-PlanningContext { - param([System.Management.Automation.PSModuleInfo]$Module, [object]$Repository, [hashtable]$Policy, [pscredential]$Credential) + param( + [System.Management.Automation.PSModuleInfo]$Module, + [object]$Repository, + [hashtable]$Policy, + [pscredential]$Credential + ) + $context = Initialize-PlanningContext $Module $Repository $Policy $lease = Open-RepositoryTransport $context.Source $failure = $null - try { Complete-PlanningContext $context $lease.Name $Credential } - catch { $failure = $_; throw } - finally { Close-RepositoryTransport $lease $failure } + try { + Complete-PlanningContext $context $lease.Name $Credential + } catch { + $failure = $_ + throw + } finally { + Close-RepositoryTransport $lease $failure + } } diff --git a/src/private/planning/Get-PlanningInstallationSnapshot.ps1 b/src/private/planning/Get-PlanningInstallationSnapshot.ps1 new file mode 100644 index 0000000..bafcceb --- /dev/null +++ b/src/private/planning/Get-PlanningInstallationSnapshot.ps1 @@ -0,0 +1,18 @@ +function Get-PlanningInstallationSnapshot { + param([object]$State) + + $installed = Select-NewestInstallation $State.Selected + $treePath = Split-Path -Parent $installed.ManifestPath + if ($State.FromBackup -and $installed.NumericVersion -eq $State.Journal.TargetNumericVersion) { + $treePath = $State.Journal.BackupPath + } + $recovery = $null + if ($State.FromBackup) { + $recovery = $State.Journal + } + [pscustomobject]@{ + Installed = $installed + InstalledTree = @(Get-FileTreeInventory $treePath) + RecoveryJournal = $recovery + } +} diff --git a/src/private/planning/Get-PlanningInstallationState.ps1 b/src/private/planning/Get-PlanningInstallationState.ps1 index 70f782f..c5ffa14 100644 --- a/src/private/planning/Get-PlanningInstallationState.ps1 +++ b/src/private/planning/Get-PlanningInstallationState.ps1 @@ -1,13 +1,22 @@ function Get-PlanningInstallationState { - param([string]$Name, [string]$Scope, [guid]$Guid) + param( + [string]$Name, + [string]$Scope, + [guid]$Guid + ) + $state = Get-RecoveryAwareInstallation $Name $Scope $Guid $otherScope = 'AllUsers' - if ($Scope -eq 'AllUsers') { $otherScope = 'CurrentUser' } - $observed = @($state.Selected; Get-InstalledModuleIdentityList $Name $otherScope) - $installed = Select-NewestInstallation $state.Selected - $treePath = Split-Path -Parent $installed.ManifestPath - if ($state.FromBackup -and $installed.NumericVersion -eq $state.Journal.TargetNumericVersion) { $treePath = $state.Journal.BackupPath } - $recovery = $null - if ($state.FromBackup) { $recovery = $state.Journal } - [pscustomobject]@{ Observed = $observed; Installed = $installed; InstalledTree = @(Get-FileTreeInventory $treePath); RecoveryJournal = $recovery } + if ($Scope -eq 'AllUsers') { + $otherScope = 'CurrentUser' + } + $observed = @($state.Selected + Get-InstalledModuleIdentityList $Name $otherScope) + $snapshot = Get-PlanningInstallationSnapshot $state + [pscustomobject]@{ + Observed = $observed + Installed = $snapshot.Installed + InstalledTree = $snapshot.InstalledTree + RecoveryJournal = $snapshot.RecoveryJournal + } } diff --git a/src/private/planning/Get-PlanningRecoveryJournal.ps1 b/src/private/planning/Get-PlanningRecoveryJournal.ps1 new file mode 100644 index 0000000..180f06e --- /dev/null +++ b/src/private/planning/Get-PlanningRecoveryJournal.ps1 @@ -0,0 +1,18 @@ +function Get-PlanningRecoveryJournal { + param( + [string]$Name, + [string]$Scope, + [guid]$Guid + ) + + $root = Resolve-ModuleInstallationRoot $Scope + $identity = [pscustomobject]@{ + ModuleName = $Name + ModuleGuid = $Guid + Scope = $Scope + DestinationRoot = $root + TargetNumericVersion = '0.0.0' + } + $operation = ConvertTo-InstallationOperation $identity $null @{} $null + Read-RecoveryJournal $operation +} diff --git a/src/private/planning/Get-PlanningUpdateTarget.ps1 b/src/private/planning/Get-PlanningUpdateTarget.ps1 new file mode 100644 index 0000000..7d87c83 --- /dev/null +++ b/src/private/planning/Get-PlanningUpdateTarget.ps1 @@ -0,0 +1,15 @@ +function Get-PlanningUpdateTarget { + param( + [object]$Context, + [string]$RepositoryName, + [pscredential]$Credential + ) + $request = @{ + Name = $Context.Module.Name + Repository = $RepositoryName + Prerelease = $Context.Policy.Channel -eq 'IncludePrerelease' + Version = $Context.Policy.VersionRange + } + $releases = @(Find-ProviderResource $request $Credential | ForEach-Object { ConvertTo-ProviderRelease $_ }) + Select-UpdateTarget $releases $Context.Policy.Channel +} diff --git a/src/private/planning/Get-RecoveryAwareInstallation.ps1 b/src/private/planning/Get-RecoveryAwareInstallation.ps1 index ae509db..8776b69 100644 --- a/src/private/planning/Get-RecoveryAwareInstallation.ps1 +++ b/src/private/planning/Get-RecoveryAwareInstallation.ps1 @@ -1,13 +1,22 @@ function Get-RecoveryAwareInstallation { - param([string]$Name, [string]$Scope, [guid]$Guid) - $root = Resolve-ModuleInstallationRoot $Scope - $identity = [pscustomobject]@{ ModuleName = $Name; ModuleGuid = $Guid; Scope = $Scope; DestinationRoot = $root; TargetNumericVersion = '0.0.0' } - $operation = ConvertTo-InstallationOperation $identity $null @{} $null - $journal = Read-RecoveryJournal $operation + param( + [string]$Name, + [string]$Scope, + [guid]$Guid + ) + + $journal = Get-PlanningRecoveryJournal $Name $Scope $Guid $selected = @(Get-RecoveryInstallationObservation $journal) $fromBackup = $selected.Count -gt 0 - if ($fromBackup) { $selected += @(Get-InstalledModuleIdentityList $Name $Scope $journal.TargetPath) } - else { $selected = @(Get-InstalledModuleIdentityList $Name $Scope) } + if ($fromBackup) { + $selected += @(Get-InstalledModuleIdentityList $Name $Scope $journal.TargetPath) + } else { + $selected = @(Get-InstalledModuleIdentityList $Name $Scope) + } Assert-SelectedInstallation $selected $Guid - [pscustomobject]@{ Selected = $selected; Journal = $journal; FromBackup = $fromBackup } + [pscustomobject]@{ + Selected = $selected + Journal = $journal + FromBackup = $fromBackup + } } diff --git a/src/private/planning/Get-UpdateDecision.ps1 b/src/private/planning/Get-UpdateDecision.ps1 index 86287e7..8d1f900 100644 --- a/src/private/planning/Get-UpdateDecision.ps1 +++ b/src/private/planning/Get-UpdateDecision.ps1 @@ -3,11 +3,14 @@ function Get-UpdateDecision { $target = $Context.Target.Version.FullVersion $advancesActive = Test-TargetAdvancesActiveVersion $target $Context.Active $installedComparison = Compare-CompleteModuleVersion $target $Context.Installed.FullVersion - $unchanged = @{ Available = $false; Disposition = 'UpToDate'; Reason = 'No release newer than both actual active and installed versions is available.'; Restart = $false } - if (-not $advancesActive) { return $unchanged } + if (-not $advancesActive) { + return ConvertTo-UpdateDecision 'UpToDate' + } if ($installedComparison -gt 0) { - return @{ Available = $true; Disposition = 'UpdateAvailable'; Reason = 'An exact newer release is available.'; Restart = $false } + return ConvertTo-UpdateDecision 'UpdateAvailable' + } + if ($Context.Active) { + return ConvertTo-UpdateDecision 'AlreadyInstalledRestartRequired' } - if ($Context.Active) { return @{ Available = $false; Disposition = 'AlreadyInstalledRestartRequired'; Reason = 'A newer version is already installed in the selected scope.'; Restart = $true } } - $unchanged + ConvertTo-UpdateDecision 'UpToDate' } diff --git a/src/private/planning/Initialize-PlanningContext.ps1 b/src/private/planning/Initialize-PlanningContext.ps1 index de88316..d137649 100644 --- a/src/private/planning/Initialize-PlanningContext.ps1 +++ b/src/private/planning/Initialize-PlanningContext.ps1 @@ -1,8 +1,15 @@ function Initialize-PlanningContext { - param([System.Management.Automation.PSModuleInfo]$Module, [object]$Repository, [hashtable]$Policy) + param( + [System.Management.Automation.PSModuleInfo]$Module, + [object]$Repository, + [hashtable]$Policy + ) + $resolved = ConvertTo-UpdatePolicy $Policy [pscustomobject]@{ - Module = $Module; Policy = $resolved; Provider = Get-ProviderContext + Module = $Module + Policy = $resolved + Provider = Get-ProviderContext Reference = Get-ModuleReferenceIdentity $Module Active = Get-ActiveModuleIdentity $Module Source = Get-SelectedRepository $Repository $resolved.ExpectedRepositoryUri diff --git a/src/private/planning/Register-UpdatePlan.ps1 b/src/private/planning/Register-UpdatePlan.ps1 index 0fd8ca0..f47499e 100644 --- a/src/private/planning/Register-UpdatePlan.ps1 +++ b/src/private/planning/Register-UpdatePlan.ps1 @@ -1,12 +1,21 @@ function Register-UpdatePlan { - param([object]$Plan, [object]$Context, [bool]$RequiresCredential) + param( + [object]$Plan, + [object]$Context, + [bool]$RequiresCredential + ) + $record = [pscustomobject]@{ Snapshot = ConvertTo-Json $Plan -Depth 12 -Compress Module = $Context.Module ReferencePath = $Context.Reference.ManifestPath WasLoaded = @(Get-Module -Name $Context.Module.Name | Where-Object { [object]::ReferenceEquals($_, $Context.Module) }).Count -gt 0 RequiresCredential = $RequiresCredential - SourceKind = if ($Plan.RepositoryName) { 'Registered' } else { 'TemporaryUri' } + SourceKind = if ($Plan.RepositoryName) { + 'Registered' + } else { + 'TemporaryUri' + } InstalledTree = @($Context.InstalledTree) } (Get-LivePlanRegistry).Add($Plan, $record) diff --git a/src/private/planning/Select-NewestInstallation.ps1 b/src/private/planning/Select-NewestInstallation.ps1 index aab9da7..f417a5f 100644 --- a/src/private/planning/Select-NewestInstallation.ps1 +++ b/src/private/planning/Select-NewestInstallation.ps1 @@ -2,8 +2,13 @@ function Select-NewestInstallation { param([object[]]$Installations) $newest = $null foreach ($installation in $Installations) { - if (-not $newest) { $newest = $installation; continue } - if ((Compare-CompleteModuleVersion $installation.FullVersion $newest.FullVersion) -gt 0) { $newest = $installation } + if (-not $newest) { + $newest = $installation + continue + } + if ((Compare-CompleteModuleVersion $installation.FullVersion $newest.FullVersion) -gt 0) { + $newest = $installation + } } $newest } diff --git a/src/private/planning/Select-UpdateTarget.ps1 b/src/private/planning/Select-UpdateTarget.ps1 index 982c367..8f78c38 100644 --- a/src/private/planning/Select-UpdateTarget.ps1 +++ b/src/private/planning/Select-UpdateTarget.ps1 @@ -1,10 +1,21 @@ function Select-UpdateTarget { - param([object[]]$Releases, [string]$Channel) + param( + [object[]]$Releases, + [string]$Channel + ) + $target = $null foreach ($release in (Get-EligibleRelease $Releases $Channel)) { - if (-not $target) { $target = $release; continue } - if ((Compare-CompleteModuleVersion $release.Version.FullVersion $target.Version.FullVersion) -gt 0) { $target = $release } + if (-not $target) { + $target = $release + continue + } + if ((Compare-CompleteModuleVersion $release.Version.FullVersion $target.Version.FullVersion) -gt 0) { + $target = $release + } + } + if (-not $target) { + Write-PMUError 'PMU.PackageNotFound' 'No eligible package was found in the explicit repository.' } - if (-not $target) { Write-PMUError 'PMU.PackageNotFound' 'No eligible package was found in the explicit repository.' } $target } diff --git a/src/private/planning/Test-TargetAdvancesActiveVersion.ps1 b/src/private/planning/Test-TargetAdvancesActiveVersion.ps1 index 47976eb..c02eeba 100644 --- a/src/private/planning/Test-TargetAdvancesActiveVersion.ps1 +++ b/src/private/planning/Test-TargetAdvancesActiveVersion.ps1 @@ -1,5 +1,11 @@ function Test-TargetAdvancesActiveVersion { - param([string]$TargetVersion, [object]$Active) - if (-not $Active) { return $true } + param( + [string]$TargetVersion, + [object]$Active + ) + + if (-not $Active) { + return $true + } (Compare-CompleteModuleVersion $TargetVersion $Active.Version.FullVersion) -gt 0 } diff --git a/src/private/provider/Assert-NativeCandidate.ps1 b/src/private/provider/Assert-NativeCandidate.ps1 new file mode 100644 index 0000000..60c1c17 --- /dev/null +++ b/src/private/provider/Assert-NativeCandidate.ps1 @@ -0,0 +1,17 @@ +function Assert-NativeCandidate { + param( + [object]$Operation, + [object]$Candidate + ) + + if (-not $Candidate) { + Write-PMUError 'PMU.UnsupportedInstallation' 'Native update cannot recognize an existing installation.' + } + $path = Assert-SafePath $Candidate.InstalledLocation + $manifest = Join-Path $path "$($Operation.Plan.ModuleName).psd1" + Assert-ObservedNativeIdentity $Operation $manifest + $null = Get-NativeInstalledSource $Operation $path + if ($Candidate.Repository -ine $Operation.RepositoryName) { + Write-PMUError 'PMU.SourceMismatch' 'Native update selected another source name.' + } +} diff --git a/src/private/provider/Assert-NativeInstallationRecord.ps1 b/src/private/provider/Assert-NativeInstallationRecord.ps1 new file mode 100644 index 0000000..21b7346 --- /dev/null +++ b/src/private/provider/Assert-NativeInstallationRecord.ps1 @@ -0,0 +1,17 @@ +function Assert-NativeInstallationRecord { + param( + [object]$Metadata, + [object]$Operation, + [string]$SelectedPath + ) + + Assert-NativeRecordField $Metadata + if ($Metadata.Name -ine $Operation.Plan.ModuleName -or $Metadata.InstalledLocation -cne $SelectedPath) { + Write-PMUError 'PMU.UnsupportedInstallation' 'Native metadata does not identify the selected installation.' + } + try { + Assert-RepositoryName $Metadata.Repository + } catch { + Write-PMUError 'PMU.UnsupportedInstallation' 'The installed native repository name is unsupported.' + } +} diff --git a/src/private/provider/Assert-NativeNuGetProvider.ps1 b/src/private/provider/Assert-NativeNuGetProvider.ps1 new file mode 100644 index 0000000..829d97d --- /dev/null +++ b/src/private/provider/Assert-NativeNuGetProvider.ps1 @@ -0,0 +1,10 @@ +function Assert-NativeNuGetProvider { + $provider = Get-PackageProvider -Name NuGet -ErrorAction Stop + if ($provider.Version -ne [version]'3.0.0.1') { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'The qualified NuGet provider is unavailable; PMU does not bootstrap providers.' + } + $root = (Join-Path $PSHOME 'Modules/PackageManagement') + [IO.Path]::DirectorySeparatorChar + if (-not $provider.ProviderPath.StartsWith($root, [StringComparison]::Ordinal)) { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'The NuGet provider is not the qualified runtime-bundled binary.' + } +} diff --git a/src/private/provider/Assert-NativeProviderContext.ps1 b/src/private/provider/Assert-NativeProviderContext.ps1 new file mode 100644 index 0000000..ad03917 --- /dev/null +++ b/src/private/provider/Assert-NativeProviderContext.ps1 @@ -0,0 +1,15 @@ +function Assert-NativeProviderContext { + param([object]$Operation) + $context = $Operation.PSObject.Properties['NativeContext'] + if (-not $context -or -not $context.Value) { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'Native mutation requires a verified owned transaction context.' + } + $expected = Get-NativeProviderRoot $Operation + if ($context.Value.Root -cne $expected) { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'The native provider root does not belong to this transaction.' + } + $paths = Get-NativeProviderPath + Assert-NativeProviderPath $Operation $paths + Assert-MatchingProperty $paths $context.Value.Paths @('Store', 'Settings', 'Config', 'Temporary', 'CurrentUser', 'AllUsers') 'PMU.UnsupportedProviderBehavior' + Assert-NativeRepositoryBinding $Operation +} diff --git a/src/private/provider/Assert-NativeProviderPath.ps1 b/src/private/provider/Assert-NativeProviderPath.ps1 new file mode 100644 index 0000000..aacd5e7 --- /dev/null +++ b/src/private/provider/Assert-NativeProviderPath.ps1 @@ -0,0 +1,17 @@ +function Assert-NativeProviderPath { + param( + [object]$Operation, + [object]$Paths + ) + + $root = (Assert-SafePath $Operation.NativeContext.Root) + [IO.Path]::DirectorySeparatorChar + foreach ($field in @('Store', 'Settings', 'Config', 'Temporary')) { + if (-not (Assert-SafePath $Paths.$field).StartsWith($root, [StringComparison]::Ordinal)) { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'A native provider store or temporary path is outside its owned root.' + } + } + if ($Paths.($Operation.Plan.Scope) -cne $Operation.Plan.DestinationRoot -or + $Paths.CurrentUser -cne $Operation.NativeContext.CurrentUserRoot) { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'Native isolation changed the approved installation destination.' + } +} diff --git a/src/private/provider/Assert-NativeRecordField.ps1 b/src/private/provider/Assert-NativeRecordField.ps1 new file mode 100644 index 0000000..4283156 --- /dev/null +++ b/src/private/provider/Assert-NativeRecordField.ps1 @@ -0,0 +1,9 @@ +function Assert-NativeRecordField { + param([object]$Metadata) + foreach ($field in @('Name', 'Repository', 'RepositorySourceLocation', 'InstalledLocation')) { + $property = $Metadata.PSObject.Properties[$field] + if (-not $property -or -not [string]$property.Value) { + Write-PMUError 'PMU.UnsupportedInstallation' 'The selected metadata is not a supported native installation.' + } + } +} diff --git a/src/private/provider/Assert-NativeRepositoryBinding.ps1 b/src/private/provider/Assert-NativeRepositoryBinding.ps1 new file mode 100644 index 0000000..1477632 --- /dev/null +++ b/src/private/provider/Assert-NativeRepositoryBinding.ps1 @@ -0,0 +1,7 @@ +function Assert-NativeRepositoryBinding { + param([object]$Operation) + $repository = Get-NativeRepositorySnapshot $Operation + if ($repository.InstallationPolicy -cne 'Trusted') { + Write-PMUError 'PMU.RepositoryChanged' 'The independently approved owned trust policy changed.' + } +} diff --git a/src/private/provider/Assert-ObservedNativeIdentity.ps1 b/src/private/provider/Assert-ObservedNativeIdentity.ps1 new file mode 100644 index 0000000..543b39a --- /dev/null +++ b/src/private/provider/Assert-ObservedNativeIdentity.ps1 @@ -0,0 +1,17 @@ +function Assert-ObservedNativeIdentity { + param( + [object]$Operation, + [string]$Manifest + ) + + $known = @($Operation.Plan.ObservedInstalledVersions | Where-Object ManifestPath -CEQ $Manifest) + if ($known.Count -ne 1 -or $known[0].Scope -cne $Operation.Plan.Scope) { + Write-PMUError 'PMU.UnsupportedInstallation' 'Native update selected an installation outside the approved scope observations.' + } + $expected = [pscustomobject]@{ + ModuleName = $Operation.Plan.ModuleName + ModuleGuid = $Operation.Plan.ModuleGuid + TargetVersion = $known[0].FullVersion + } + Assert-PackageIdentity (Get-ManifestIdentity $Manifest) $expected +} diff --git a/src/private/provider/ConvertTo-NativeWorkerPayload.ps1 b/src/private/provider/ConvertTo-NativeWorkerPayload.ps1 new file mode 100644 index 0000000..da6281f --- /dev/null +++ b/src/private/provider/ConvertTo-NativeWorkerPayload.ps1 @@ -0,0 +1,6 @@ +function ConvertTo-NativeWorkerPayload { + param([object]$Context) + + $serialized = [Management.Automation.PSSerializer]::Serialize($Context, 20) + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($serialized)) +} diff --git a/src/private/provider/ConvertTo-ProviderRelease.ps1 b/src/private/provider/ConvertTo-ProviderRelease.ps1 index 613b955..7895038 100644 --- a/src/private/provider/ConvertTo-ProviderRelease.ps1 +++ b/src/private/provider/ConvertTo-ProviderRelease.ps1 @@ -1,8 +1,15 @@ function ConvertTo-ProviderRelease { param([object]$Resource) $version = "$($Resource.Version)" - if ($Resource.Prerelease) { $version += "-$($Resource.Prerelease)" } + if ($Resource.Prerelease) { + $version += "-$($Resource.Prerelease)" + } $identity = ConvertTo-CompleteModuleVersion $version $license = Get-ProviderLicenseRequirement $Resource - [pscustomobject]@{ Name = $Resource.Name; Version = $identity; RequiresLicenseAcceptance = $license; Dependencies = @($Resource.Dependencies | ForEach-Object { "$($_.Name)" }) } + [pscustomobject]@{ + Name = $Resource.Name + Version = $identity + RequiresLicenseAcceptance = $license + Dependencies = @($Resource.Dependencies | ForEach-Object { "$($_.Name)" }) + } } diff --git a/src/private/provider/ConvertTo-ProviderSourceUri.ps1 b/src/private/provider/ConvertTo-ProviderSourceUri.ps1 new file mode 100644 index 0000000..77bad44 --- /dev/null +++ b/src/private/provider/ConvertTo-ProviderSourceUri.ps1 @@ -0,0 +1,8 @@ +function ConvertTo-ProviderSourceUri { + param([string]$Source) + $uri = [uri]$Source + if ([IO.Path]::IsPathFullyQualified($Source)) { + $uri = [uri]::new($Source, [UriKind]::Absolute) + } + Assert-RepositoryUri $uri +} diff --git a/src/private/provider/Find-ProviderResource.ps1 b/src/private/provider/Find-ProviderResource.ps1 index ea3ea7f..b451bf4 100644 --- a/src/private/provider/Find-ProviderResource.ps1 +++ b/src/private/provider/Find-ProviderResource.ps1 @@ -1,9 +1,20 @@ function Find-ProviderResource { - param([hashtable]$Request, [pscredential]$Credential) - $parameters = @{ Name = $Request.Name; Repository = $Request.Repository; Prerelease = $Request.Prerelease; ErrorAction = 'Stop'; WarningAction = 'SilentlyContinue'; Verbose = $false; Debug = $false } - $parameters.Version = '*' - if ($Request.Version) { $parameters.Version = $Request.Version } - if ($Credential) { $parameters.Credential = $Credential } - try { Microsoft.PowerShell.PSResourceGet\Find-PSResource @parameters } - catch { Write-ProviderError $_ 'PMU.DiscoveryFailed' } + param( + [hashtable]$Request, + [pscredential]$Credential + ) + + $parameters = Get-ProviderInvocationParameter + $parameters.Name = $Request.Name + $parameters.Repository = $Request.Repository + $parameters.Prerelease = $Request.Prerelease + $parameters.Version = $Request.Version ? $Request.Version : '*' + if ($Credential) { + $parameters.Credential = $Credential + } + try { + Microsoft.PowerShell.PSResourceGet\Find-PSResource @parameters + } catch { + Write-ProviderError $_ 'PMU.DiscoveryFailed' + } } diff --git a/src/private/provider/Get-NativeInstalledSource.ps1 b/src/private/provider/Get-NativeInstalledSource.ps1 new file mode 100644 index 0000000..aa13b88 --- /dev/null +++ b/src/private/provider/Get-NativeInstalledSource.ps1 @@ -0,0 +1,17 @@ +function Get-NativeInstalledSource { + param( + [object]$Operation, + [string]$SelectedPath + ) + + $metadata = Read-NativeInstallationRecord $SelectedPath + Assert-NativeInstallationRecord $metadata $Operation $SelectedPath + $uri = ConvertTo-ProviderSourceUri $metadata.RepositorySourceLocation + if ($uri -cne $Operation.Plan.RepositoryUri) { + Write-PMUError 'PMU.SourceMismatch' 'The selected native installation came from another repository.' + } + [pscustomobject]@{ + Name = [string]$metadata.Repository + Uri = $uri + } +} diff --git a/src/private/provider/Get-NativeProviderPath.ps1 b/src/private/provider/Get-NativeProviderPath.ps1 new file mode 100644 index 0000000..d8de0b4 --- /dev/null +++ b/src/private/provider/Get-NativeProviderPath.ps1 @@ -0,0 +1,16 @@ +function Get-NativeProviderPath { + $provider = @(Get-Module -Name PowerShellGet) + if ($provider.Count -ne 1 -or $provider[0].Version -ne [version]'2.2.5') { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'The qualified native provider is not loaded.' + } + & $provider[0] { + [pscustomobject]@{ + Store = $script:PSGetModuleSourcesFilePath + Settings = $script:PSGetSettingsFilePath + Config = $script:PSGetProgramDataPath + Temporary = [IO.Path]::GetTempPath() + CurrentUser = $script:PSGetPath.CurrentUserModules + AllUsers = $script:PSGetPath.AllUsersModules + } + } +} diff --git a/src/private/provider/Get-NativeProviderRoot.ps1 b/src/private/provider/Get-NativeProviderRoot.ps1 new file mode 100644 index 0000000..ae4ba4d --- /dev/null +++ b/src/private/provider/Get-NativeProviderRoot.ps1 @@ -0,0 +1,11 @@ +function Get-NativeProviderRoot { + param( + [object]$Operation, + [object]$Journal = $Operation.Journal + ) + + if (-not $Journal -or -not $Journal.PSObject.Properties['TransactionId']) { + Write-PMUError 'PMU.UnsupportedProviderBehavior' 'A native provider root requires its original transaction nonce.' + } + Join-Path $Operation.WorkRoot "native-$($Journal.TransactionId)" +} diff --git a/src/private/provider/Get-NativeRepositoryParameter.ps1 b/src/private/provider/Get-NativeRepositoryParameter.ps1 new file mode 100644 index 0000000..8d5aa89 --- /dev/null +++ b/src/private/provider/Get-NativeRepositoryParameter.ps1 @@ -0,0 +1,19 @@ +function Get-NativeRepositoryParameter { + param([object]$Operation) + + $uri = [uri]$Operation.Plan.RepositoryUri + $location = $uri.AbsoluteUri + if ($uri.IsFile) { + $location = $uri.LocalPath + } + $parameters = @{ + Name = $Operation.RepositoryName + SourceLocation = $location + InstallationPolicy = 'Untrusted' + ErrorAction = 'Stop' + } + if ($Operation.Credential) { + $parameters.Credential = $Operation.Credential + } + $parameters +} diff --git a/src/private/provider/Get-NativeRepositorySnapshot.ps1 b/src/private/provider/Get-NativeRepositorySnapshot.ps1 new file mode 100644 index 0000000..ef531f8 --- /dev/null +++ b/src/private/provider/Get-NativeRepositorySnapshot.ps1 @@ -0,0 +1,16 @@ +function Get-NativeRepositorySnapshot { + param([object]$Operation) + Assert-NativeProviderPath $Operation (Get-NativeProviderPath) + $repositories = @(PowerShellGet\Get-PSRepository -ErrorAction Stop) + if ($repositories.Count -ne 1) { + Write-PMUError 'PMU.RepositoryChanged' 'The owned native store must contain only the approved repository.' + } + $repository = $repositories[0] + if ($repository.Name -ine $Operation.RepositoryName) { + Write-PMUError 'PMU.RepositoryChanged' 'The owned native source name changed.' + } + if ((ConvertTo-ProviderSourceUri $repository.SourceLocation) -cne $Operation.Plan.RepositoryUri) { + Write-PMUError 'PMU.SourceMismatch' 'The owned native repository no longer matches the approved URI.' + } + $repository +} diff --git a/src/private/provider/Get-NativeUpdateParameter.ps1 b/src/private/provider/Get-NativeUpdateParameter.ps1 new file mode 100644 index 0000000..cdef6b3 --- /dev/null +++ b/src/private/provider/Get-NativeUpdateParameter.ps1 @@ -0,0 +1,12 @@ +function Get-NativeUpdateParameter { + param([object]$Operation) + $parameters = Get-ProviderMutationParameter $Operation + $plan = $Operation.Plan + if (Test-SameBaseStablePromotion $plan) { + $null = $parameters.Remove('RequiredVersion') + $null = $parameters.Remove('AllowPrerelease') + $parameters.MaximumVersion = $plan.TargetVersion + } + $parameters.Scope = $plan.Scope + $parameters +} diff --git a/src/private/provider/Get-NativeWorkerBootstrap.ps1 b/src/private/provider/Get-NativeWorkerBootstrap.ps1 new file mode 100644 index 0000000..170251f --- /dev/null +++ b/src/private/provider/Get-NativeWorkerBootstrap.ps1 @@ -0,0 +1,16 @@ +function Get-NativeWorkerBootstrap { + @' +$ErrorActionPreference = 'Stop' +$encodedContext = [Console]::In.ReadLine() +$contextBytes = [Convert]::FromBase64String($encodedContext) +$serializedContext = [Text.Encoding]::UTF8.GetString($contextBytes) +$context = [Management.Automation.PSSerializer]::Deserialize($serializedContext) +$module = Import-Module -Name $context.ModulePath -PassThru + +& $module { + param($workerContext) + + Invoke-NativeWorker -Context $workerContext +} $context +'@ +} diff --git a/src/private/provider/Get-NativeWorkerDirectoryMap.ps1 b/src/private/provider/Get-NativeWorkerDirectoryMap.ps1 new file mode 100644 index 0000000..2c1dcb6 --- /dev/null +++ b/src/private/provider/Get-NativeWorkerDirectoryMap.ps1 @@ -0,0 +1,11 @@ +function Get-NativeWorkerDirectoryMap { + @{ + XDG_CONFIG_HOME = 'config' + XDG_CACHE_HOME = 'cache' + LOCALAPPDATA = 'local' + ProgramData = 'program' + TMPDIR = 'tmp' + TEMP = 'tmp' + TMP = 'tmp' + } +} diff --git a/src/private/provider/Get-ProviderContext.ps1 b/src/private/provider/Get-ProviderContext.ps1 index 51cef70..ec65b68 100644 --- a/src/private/provider/Get-ProviderContext.ps1 +++ b/src/private/provider/Get-ProviderContext.ps1 @@ -2,8 +2,11 @@ function Get-ProviderContext { param() $provider = Get-Module Microsoft.PowerShell.PSResourceGet if (-not $provider) { - try { $provider = Import-Module Microsoft.PowerShell.PSResourceGet -RequiredVersion 1.2.0 -PassThru -ErrorAction Stop } - catch { Write-PMUError 'PMU.ProviderUnavailable' 'PSResourceGet 1.2.0 must be available; PMU does not install it.' } + try { + $provider = Import-Module Microsoft.PowerShell.PSResourceGet -RequiredVersion 1.2.0 -PassThru -ErrorAction Stop + } catch { + Write-PMUError 'PMU.ProviderUnavailable' 'PSResourceGet 1.2.0 must be available; PMU does not install it.' + } } if ($provider.Version -ne [version]'1.2.0') { Write-PMUError 'PMU.ProviderIncompatible' 'Only the qualified PSResourceGet 1.2.0 baseline is enabled.' diff --git a/src/private/provider/Get-ProviderInvocationParameter.ps1 b/src/private/provider/Get-ProviderInvocationParameter.ps1 new file mode 100644 index 0000000..b42a1d9 --- /dev/null +++ b/src/private/provider/Get-ProviderInvocationParameter.ps1 @@ -0,0 +1,8 @@ +function Get-ProviderInvocationParameter { + @{ + ErrorAction = 'Stop' + WarningAction = 'SilentlyContinue' + Verbose = $false + Debug = $false + } +} diff --git a/src/private/provider/Get-ProviderLicenseRequirement.ps1 b/src/private/provider/Get-ProviderLicenseRequirement.ps1 index a543fb7..c22fb2e 100644 --- a/src/private/provider/Get-ProviderLicenseRequirement.ps1 +++ b/src/private/provider/Get-ProviderLicenseRequirement.ps1 @@ -1,11 +1,16 @@ function Get-ProviderLicenseRequirement { param([object]$Resource) $metadata = $Resource.AdditionalMetadata - if (-not $metadata) { return $true } + if (-not $metadata) { + return $true + } $keys = @($metadata.Keys | Where-Object { $_ -ieq 'RequireLicenseAcceptance' }) - if ($keys.Count -ne 1) { return $true } + if ($keys.Count -ne 1) { + return $true + } $value = $metadata[$keys[0]] - if ($value -is [bool]) { return $value } - if ($value -isnot [string]) { return $true } - $value -ine 'false' + if ($value -is [bool]) { + return $value + } + $value -isnot [string] -or $value -ine 'false' } diff --git a/src/private/provider/Get-ProviderMutationParameter.ps1 b/src/private/provider/Get-ProviderMutationParameter.ps1 index e822270..0a292cf 100644 --- a/src/private/provider/Get-ProviderMutationParameter.ps1 +++ b/src/private/provider/Get-ProviderMutationParameter.ps1 @@ -1,12 +1,14 @@ function Get-ProviderMutationParameter { param([object]$Operation) $plan = $Operation.Plan - $parameters = @{ - Name = $plan.ModuleName; Version = "[$($plan.TargetVersion)]"; Repository = Get-OperationRepositoryName $Operation - Prerelease = $plan.TargetIsPrerelease; TrustRepository = [bool]$Operation.Consent.TrustRepository - AcceptLicense = [bool]$Operation.Consent.AcceptLicense; Quiet = $true; Confirm = $false - ErrorAction = 'Stop'; WarningAction = 'SilentlyContinue'; Verbose = $false; Debug = $false + $parameters = Get-ProviderInvocationParameter + $parameters.Name = $plan.ModuleName + $parameters.RequiredVersion = $plan.TargetVersion + $parameters.AllowPrerelease = $plan.TargetIsPrerelease + $parameters.AcceptLicense = [bool]$Operation.Consent.AcceptLicense + $parameters.Confirm = $false + if ($Operation.Credential) { + $parameters.Credential = $Operation.Credential } - if ($Operation.Credential) { $parameters.Credential = $Operation.Credential } $parameters } diff --git a/src/private/provider/Initialize-NativeProvider.ps1 b/src/private/provider/Initialize-NativeProvider.ps1 new file mode 100644 index 0000000..ae8384c --- /dev/null +++ b/src/private/provider/Initialize-NativeProvider.ps1 @@ -0,0 +1,11 @@ +function Initialize-NativeProvider { + param([object]$Operation) + Import-Module (Join-Path $PSHOME 'Modules/PackageManagement/PackageManagement.psd1') -RequiredVersion 1.4.8.1 -ErrorAction Stop + Import-Module (Join-Path $PSHOME 'Modules/PowerShellGet/PowerShellGet.psd1') -RequiredVersion 2.2.5 -ErrorAction Stop + $paths = Get-NativeProviderPath + Assert-NativeProviderPath $Operation $paths + $Operation.NativeContext.Paths = $paths + Assert-NativeNuGetProvider + Initialize-NativeRepository $Operation + Assert-NativeProviderContext $Operation +} diff --git a/src/private/provider/Initialize-NativeRepository.ps1 b/src/private/provider/Initialize-NativeRepository.ps1 new file mode 100644 index 0000000..6724411 --- /dev/null +++ b/src/private/provider/Initialize-NativeRepository.ps1 @@ -0,0 +1,13 @@ +function Initialize-NativeRepository { + param([object]$Operation) + if (-not $Operation.Plan.RepositoryTrusted -and -not $Operation.Consent.TrustRepository) { + Write-PMUError 'PMU.RepositoryTrustRequired' 'The native worker requires independent repository trust approval.' + } + if (-not (Initialize-NativeRepositoryStore $Operation)) { + Register-NativeRepository $Operation + $null = Initialize-NativeRepositoryStore $Operation + } + $null = Get-NativeRepositorySnapshot $Operation + $null = PowerShellGet\Set-PSRepository -Name $Operation.RepositoryName -InstallationPolicy Trusted -ErrorAction Stop + Assert-NativeRepositoryBinding $Operation +} diff --git a/src/private/provider/Initialize-NativeRepositoryStore.ps1 b/src/private/provider/Initialize-NativeRepositoryStore.ps1 new file mode 100644 index 0000000..812443b --- /dev/null +++ b/src/private/provider/Initialize-NativeRepositoryStore.ps1 @@ -0,0 +1,14 @@ +function Initialize-NativeRepositoryStore { + param([object]$Operation) + Assert-NativeProviderPath $Operation (Get-NativeProviderPath) + $repositories = @(PowerShellGet\Get-PSRepository -ErrorAction Stop -WarningAction SilentlyContinue) + $registered = $false + foreach ($repository in $repositories) { + if ($repository.Name -ieq $Operation.RepositoryName) { + $registered = $true + } else { + $null = PowerShellGet\Unregister-PSRepository -Name $repository.Name -ErrorAction Stop + } + } + $registered +} diff --git a/src/private/provider/Initialize-NativeWorkerEnvironment.ps1 b/src/private/provider/Initialize-NativeWorkerEnvironment.ps1 new file mode 100644 index 0000000..b55cf4d --- /dev/null +++ b/src/private/provider/Initialize-NativeWorkerEnvironment.ps1 @@ -0,0 +1,15 @@ +function Initialize-NativeWorkerEnvironment { + param( + [Diagnostics.ProcessStartInfo]$Start, + [string]$Root + ) + + $paths = Get-NativeWorkerDirectoryMap + Initialize-ControlledDirectory $Root + foreach ($key in $paths.Keys) { + $path = Join-Path $Root $paths[$key] + Initialize-ControlledDirectory $path + $Start.Environment[$key] = $path + } + $Start.WorkingDirectory = $Root +} diff --git a/src/private/provider/Initialize-NativeWorkerStartInfo.ps1 b/src/private/provider/Initialize-NativeWorkerStartInfo.ps1 new file mode 100644 index 0000000..1e5d357 --- /dev/null +++ b/src/private/provider/Initialize-NativeWorkerStartInfo.ps1 @@ -0,0 +1,16 @@ +function Initialize-NativeWorkerStartInfo { + param([object]$Context) + + $start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) + $bootstrap = Get-NativeWorkerBootstrap + + foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $bootstrap)) { + $start.ArgumentList.Add($argument) + } + + Initialize-NativeWorkerEnvironment $start $Context.Operation.NativeContext.Root + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start +} diff --git a/src/private/provider/Install-ProviderResource.ps1 b/src/private/provider/Install-ProviderResource.ps1 index 3c3e2d7..4895c75 100644 --- a/src/private/provider/Install-ProviderResource.ps1 +++ b/src/private/provider/Install-ProviderResource.ps1 @@ -1,8 +1,10 @@ function Install-ProviderResource { param([object]$Operation) - $parameters = Get-ProviderMutationParameter $Operation - $parameters.Scope = $Operation.Plan.Scope - if ($Operation.Reinstall) { $parameters.Reinstall = $true } - try { $null = Microsoft.PowerShell.PSResourceGet\Install-PSResource @parameters } - catch { Write-ProviderError $_ 'PMU.InstallationFailed' } + Assert-NativeProviderContext $Operation + $parameters = Get-NativeUpdateParameter $Operation + try { + $null = PowerShellGet\Update-Module @parameters + } catch { + Write-ProviderError $_ 'PMU.InstallationFailed' + } } diff --git a/src/private/provider/Invoke-NativeWorkerStart.ps1 b/src/private/provider/Invoke-NativeWorkerStart.ps1 new file mode 100644 index 0000000..ce16856 --- /dev/null +++ b/src/private/provider/Invoke-NativeWorkerStart.ps1 @@ -0,0 +1,22 @@ +function Invoke-NativeWorkerStart { + param( + [object]$Operation, + [object]$Context + ) + + $process = [Diagnostics.Process]::Start((Initialize-NativeWorkerStartInfo $Context)) + $worker = [pscustomobject]@{ + Process = $process + Errors = $process.StandardError.ReadToEndAsync() + ExitCode = $null + } + try { + $payload = ConvertTo-NativeWorkerPayload $Context + $process.StandardInput.AutoFlush = $true + $process.StandardInput.WriteLine($payload) + $worker + } catch { + Complete-NativeWorkerLease $Operation $worker + Write-ProviderError $_ 'PMU.InstallationFailed' + } +} diff --git a/src/private/provider/Read-NativeInstallationRecord.ps1 b/src/private/provider/Read-NativeInstallationRecord.ps1 new file mode 100644 index 0000000..7b90fde --- /dev/null +++ b/src/private/provider/Read-NativeInstallationRecord.ps1 @@ -0,0 +1,12 @@ +function Read-NativeInstallationRecord { + param([string]$SelectedPath) + $path = Join-Path $SelectedPath 'PSGetModuleInfo.xml' + if (-not (Test-Path -LiteralPath $path)) { + Write-PMUError 'PMU.UnsupportedInstallation' 'The selected installation has no native provider metadata.' + } + try { + Import-Clixml -LiteralPath $path -ErrorAction Stop + } catch { + Write-PMUError 'PMU.UnsupportedInstallation' 'The selected native installation metadata is unreadable.' + } +} diff --git a/src/private/provider/Register-NativeRepository.ps1 b/src/private/provider/Register-NativeRepository.ps1 new file mode 100644 index 0000000..797a941 --- /dev/null +++ b/src/private/provider/Register-NativeRepository.ps1 @@ -0,0 +1,9 @@ +function Register-NativeRepository { + param([object]$Operation) + if ($Operation.RepositoryName -ieq 'PSGallery') { + $null = PowerShellGet\Register-PSRepository -Default -ErrorAction Stop + return + } + $parameters = Get-NativeRepositoryParameter $Operation + $null = PowerShellGet\Register-PSRepository @parameters +} diff --git a/src/private/provider/Save-ProviderResource.ps1 b/src/private/provider/Save-ProviderResource.ps1 index d797942..07e2901 100644 --- a/src/private/provider/Save-ProviderResource.ps1 +++ b/src/private/provider/Save-ProviderResource.ps1 @@ -1,8 +1,12 @@ function Save-ProviderResource { param([object]$Operation) + Assert-NativeProviderContext $Operation $parameters = Get-ProviderMutationParameter $Operation $parameters.Path = $Operation.StageRoot - $parameters.IncludeXml = $true - try { $null = Microsoft.PowerShell.PSResourceGet\Save-PSResource @parameters } - catch { Write-ProviderError $_ 'PMU.InstallationFailed' } + $parameters.Repository = Get-OperationRepositoryName $Operation + try { + $null = PowerShellGet\Save-Module @parameters + } catch { + Write-ProviderError $_ 'PMU.InstallationFailed' + } } diff --git a/src/private/provider/Test-SameBaseStablePromotion.ps1 b/src/private/provider/Test-SameBaseStablePromotion.ps1 new file mode 100644 index 0000000..29e97b0 --- /dev/null +++ b/src/private/provider/Test-SameBaseStablePromotion.ps1 @@ -0,0 +1,11 @@ +function Test-SameBaseStablePromotion { + param([object]$Plan) + if ($Plan.TargetIsPrerelease) { + return $false + } + $installed = ConvertTo-CompleteModuleVersion $Plan.InstalledVersion + if (-not $installed.IsPrerelease) { + return $false + } + $installed.NumericVersion -ceq $Plan.TargetNumericVersion +} diff --git a/src/private/provider/Write-ProviderError.ps1 b/src/private/provider/Write-ProviderError.ps1 index e2ab3c2..f4f395f 100644 --- a/src/private/provider/Write-ProviderError.ps1 +++ b/src/private/provider/Write-ProviderError.ps1 @@ -1,7 +1,15 @@ function Write-ProviderError { - param([System.Management.Automation.ErrorRecord]$Failure, [string]$DefaultId) + param( + [System.Management.Automation.ErrorRecord]$Failure, + [string]$DefaultId + ) + $id = $DefaultId - if ($Failure.Exception -is [UnauthorizedAccessException] -or $Failure.Exception.Message -match '(?i)access.+denied|permission denied') { $id = 'PMU.PermissionDenied' } - if ($Failure.Exception.Message -match '(?i)\b401\b|\b403\b|unauthorized|authentication') { $id = 'PMU.AuthenticationFailed' } + if ($Failure.Exception -is [UnauthorizedAccessException] -or $Failure.Exception.Message -match '(?i)access.+denied|permission denied') { + $id = 'PMU.PermissionDenied' + } + if ($Failure.Exception.Message -match '(?i)\b401\b|\b403\b|unauthorized|authentication') { + $id = 'PMU.AuthenticationFailed' + } Write-PMUError $id 'The provider operation failed. Provider diagnostics were withheld to protect credentials.' 'Check repository authentication and scope permissions, then obtain a fresh plan. PMU never elevates or changes scope.' } diff --git a/src/private/recovery/Assert-BackupReferenceIdentity.ps1 b/src/private/recovery/Assert-BackupReferenceIdentity.ps1 index 1d1750e..1437ccc 100644 --- a/src/private/recovery/Assert-BackupReferenceIdentity.ps1 +++ b/src/private/recovery/Assert-BackupReferenceIdentity.ps1 @@ -1,6 +1,12 @@ function Assert-BackupReferenceIdentity { - param([object]$Reference, [object]$Journal) - if (-not $Journal) { Write-PMUError 'PMU.UnsupportedInstallation' 'The reference must identify an installed module or a validated unfinished-operation backup.' } + param( + [object]$Reference, + [object]$Journal + ) + + if (-not $Journal) { + Write-PMUError 'PMU.UnsupportedInstallation' 'The reference must identify an installed module or a validated unfinished-operation backup.' + } $backup = Get-VerifiedRecoveryBackup $Journal 'PMU.RecoveryRequired' Assert-MatchingProperty $Reference $backup @('Name', 'Guid', 'ManifestPath') 'PMU.UnsupportedInstallation' if ($Reference.Version.FullVersion -cne $backup.Version.FullVersion) { diff --git a/src/private/recovery/Assert-RecoveryJournal.ps1 b/src/private/recovery/Assert-RecoveryJournal.ps1 index 5023904..d31ebb4 100644 --- a/src/private/recovery/Assert-RecoveryJournal.ps1 +++ b/src/private/recovery/Assert-RecoveryJournal.ps1 @@ -1,9 +1,13 @@ function Assert-RecoveryJournal { - param([object]$Journal, [object]$Operation) + param( + [object]$Journal, + [object]$Operation + ) + Assert-RecoveryJournalSchema $Journal $plan = $Operation.Plan $version = ConvertTo-CompleteModuleVersion $Journal.TargetVersion - $expected = [pscustomobject]@{ SchemaVersion = 1; ModuleName = $plan.ModuleName; ModuleGuid = "$($plan.ModuleGuid)"; Scope = $plan.Scope; DestinationRoot = $plan.DestinationRoot; TargetNumericVersion = $version.NumericVersion } + $expected = Get-ExpectedRecoveryJournalIdentity $plan $version Assert-MatchingProperty $Journal $expected @('SchemaVersion', 'ModuleName', 'ModuleGuid', 'Scope', 'DestinationRoot', 'TargetNumericVersion') 'PMU.RecoveryRequired' Assert-RecoveryPath $Journal $Operation if ($Journal.State -notin @('Preparation', 'BackupReady', 'ReplacementInProgress', 'Verification', 'Completed', 'RecoveryRequired', 'RecoveryCompleted')) { diff --git a/src/private/recovery/Assert-RecoveryJournalSchema.ps1 b/src/private/recovery/Assert-RecoveryJournalSchema.ps1 index ca055f4..34485b6 100644 --- a/src/private/recovery/Assert-RecoveryJournalSchema.ps1 +++ b/src/private/recovery/Assert-RecoveryJournalSchema.ps1 @@ -1,11 +1,17 @@ function Assert-RecoveryJournalSchema { param([object]$Journal) - $fields = 'SchemaVersion ModuleName Scope DestinationRoot ModuleGuid PreviousVersion TargetVersion TargetNumericVersion TargetPath JournalPath StageRoot StagedPath BackupPath Reinstall State RecoveryStatus StagedTree BackupTree'.Split(' ') + $fields = Get-RecoveryJournalField $Journal $actual = @($Journal.PSObject.Properties.Name | Sort-Object) if (($actual -join '|') -cne (($fields | Sort-Object) -join '|')) { Write-PMUError 'PMU.RecoveryRequired' 'The recovery journal schema is invalid.' } - if ($Journal.Reinstall -isnot [bool]) { Write-PMUError 'PMU.RecoveryRequired' 'The recovery journal mutation flag is not Boolean.' } - if ($Journal.StagedTree -isnot [array]) { Write-PMUError 'PMU.RecoveryRequired' 'The recovery reference tree is not an array.' } - if ($Journal.BackupTree -isnot [array]) { Write-PMUError 'PMU.RecoveryRequired' 'The recovery backup tree is not an array.' } + if ($Journal.Reinstall -isnot [bool]) { + Write-PMUError 'PMU.RecoveryRequired' 'The recovery journal mutation flag is not Boolean.' + } + if ($Journal.StagedTree -isnot [array]) { + Write-PMUError 'PMU.RecoveryRequired' 'The recovery reference tree is not an array.' + } + if ($Journal.BackupTree -isnot [array]) { + Write-PMUError 'PMU.RecoveryRequired' 'The recovery backup tree is not an array.' + } } diff --git a/src/private/recovery/Assert-RecoveryPath.ps1 b/src/private/recovery/Assert-RecoveryPath.ps1 index bfd73b1..d197977 100644 --- a/src/private/recovery/Assert-RecoveryPath.ps1 +++ b/src/private/recovery/Assert-RecoveryPath.ps1 @@ -1,12 +1,20 @@ function Assert-RecoveryPath { - param([object]$Journal, [object]$Operation) + param( + [object]$Journal, + [object]$Operation + ) + $expected = @{ - JournalPath = $Operation.JournalPath; StageRoot = $Operation.StageRoot; BackupPath = $Operation.BackupPath + JournalPath = $Operation.JournalPath + StageRoot = $Operation.StageRoot + BackupPath = $Operation.BackupPath StagedPath = Join-Path $Operation.StageRoot "$($Journal.ModuleName)/$($Journal.TargetNumericVersion)" TargetPath = Join-Path $Operation.Plan.DestinationRoot "$($Journal.ModuleName)/$($Journal.TargetNumericVersion)" } foreach ($key in $expected.Keys) { - if ($Journal.$key -cne $expected[$key]) { Write-PMUError 'PMU.RecoveryRequired' 'Recovery paths are not the expected PMU-controlled paths.' } + if ($Journal.$key -cne $expected[$key]) { + Write-PMUError 'PMU.RecoveryRequired' 'Recovery paths are not the expected PMU-controlled paths.' + } $null = Assert-SafePath $Journal.$key } } diff --git a/src/private/recovery/Assert-RecoveryTargetIdentity.ps1 b/src/private/recovery/Assert-RecoveryTargetIdentity.ps1 index b032ec6..b7a5bdd 100644 --- a/src/private/recovery/Assert-RecoveryTargetIdentity.ps1 +++ b/src/private/recovery/Assert-RecoveryTargetIdentity.ps1 @@ -2,13 +2,16 @@ function Assert-RecoveryTargetIdentity { param([object]$Journal) $manifest = Join-Path $Journal.TargetPath "$($Journal.ModuleName).psd1" $data = Get-SurvivingManifestData $manifest - $expected = @{ GUID = $Journal.ModuleGuid; ModuleVersion = $Journal.TargetNumericVersion } - $errorId = @{ GUID = 'PMU.GuidMismatch'; ModuleVersion = 'PMU.ModuleIdentityMismatch' } + $expected = @{ + GUID = $Journal.ModuleGuid + ModuleVersion = $Journal.TargetNumericVersion + } + $errorId = @{ + GUID = 'PMU.GuidMismatch' + ModuleVersion = 'PMU.ModuleIdentityMismatch' + } foreach ($key in $expected.Keys) { - if (-not $data[$key]) { continue } - if ("$($data[$key])" -ine $expected[$key]) { - Write-PMUError $errorId[$key] 'Recovery refuses to delete an installation with an unexpected manifest identity.' - } + Assert-SurvivingManifestProperty $data[$key] $expected[$key] $errorId[$key] } Assert-RecoveryTargetVersion $data $Journal } diff --git a/src/private/recovery/Assert-RecoveryTargetVersion.ps1 b/src/private/recovery/Assert-RecoveryTargetVersion.ps1 index 58314bd..ef181dc 100644 --- a/src/private/recovery/Assert-RecoveryTargetVersion.ps1 +++ b/src/private/recovery/Assert-RecoveryTargetVersion.ps1 @@ -1,6 +1,12 @@ function Assert-RecoveryTargetVersion { - param([hashtable]$Manifest, [object]$Journal) - if (-not $Manifest['ModuleVersion']) { return } + param( + [hashtable]$Manifest, + [object]$Journal + ) + + if (-not $Manifest['ModuleVersion']) { + return + } $version = Get-ManifestModuleVersion $Manifest if ($version.FullVersion -cnotin @($Journal.PreviousVersion, $Journal.TargetVersion)) { Write-PMUError 'PMU.ModuleIdentityMismatch' 'Recovery refuses to remove an unrelated full-version identity from the numeric target.' diff --git a/src/private/recovery/Assert-RecoveryTransactionId.ps1 b/src/private/recovery/Assert-RecoveryTransactionId.ps1 new file mode 100644 index 0000000..c046b95 --- /dev/null +++ b/src/private/recovery/Assert-RecoveryTransactionId.ps1 @@ -0,0 +1,7 @@ +function Assert-RecoveryTransactionId { + param([object]$TransactionId) + $parsed = [guid]::Empty + if ($TransactionId -isnot [string] -or -not [guid]::TryParseExact($TransactionId, 'N', [ref]$parsed)) { + Write-PMUError 'PMU.RecoveryRequired' 'The recovery transaction identifier is invalid.' + } +} diff --git a/src/private/recovery/Assert-SurvivingManifestProperty.ps1 b/src/private/recovery/Assert-SurvivingManifestProperty.ps1 new file mode 100644 index 0000000..e1b4351 --- /dev/null +++ b/src/private/recovery/Assert-SurvivingManifestProperty.ps1 @@ -0,0 +1,14 @@ +function Assert-SurvivingManifestProperty { + param( + [object]$Actual, + [object]$Expected, + [string]$ErrorId + ) + + if (-not $Actual) { + return + } + if ("$Actual" -ine $Expected) { + Write-PMUError $ErrorId 'Recovery refuses to delete an installation with an unexpected manifest identity.' + } +} diff --git a/src/private/recovery/Clear-CompletedOperation.ps1 b/src/private/recovery/Clear-CompletedOperation.ps1 index 069c9ac..cbee363 100644 --- a/src/private/recovery/Clear-CompletedOperation.ps1 +++ b/src/private/recovery/Clear-CompletedOperation.ps1 @@ -1,11 +1,13 @@ function Clear-CompletedOperation { param([object]$Operation) $journal = Read-RecoveryJournal $Operation - if (-not $journal) { return } + if (-not $journal) { + return + } if ($journal.State -notin @('Completed', 'RecoveryCompleted')) { Write-PMUError 'PMU.RecoveryRequired' 'Unfinished recovery evidence cannot be discarded.' } - foreach ($path in @($Operation.StageRoot, $Operation.BackupPath)) { + foreach ($path in Get-CompletedCleanupPath $Operation $journal) { $null = Assert-SafePath $path if (Test-Path -LiteralPath $path) { $null = @(Get-FileTreeInventory $path) diff --git a/src/private/recovery/Clear-ControlledTarget.ps1 b/src/private/recovery/Clear-ControlledTarget.ps1 index 52edc30..33e7e51 100644 --- a/src/private/recovery/Clear-ControlledTarget.ps1 +++ b/src/private/recovery/Clear-ControlledTarget.ps1 @@ -1,7 +1,9 @@ function Clear-ControlledTarget { param([object]$Journal) $expected = Join-Path $Journal.DestinationRoot "$($Journal.ModuleName)/$($Journal.TargetNumericVersion)" - if ($expected -cne $Journal.TargetPath) { Write-PMUError 'PMU.RecoveryRequired' 'Refusing an unexpected removal path.' } + if ($expected -cne $Journal.TargetPath) { + Write-PMUError 'PMU.RecoveryRequired' 'Refusing an unexpected removal path.' + } $null = Assert-SafePath $expected if (Test-Path -LiteralPath $expected) { $null = @(Get-FileTreeInventory $expected) diff --git a/src/private/recovery/Complete-RecoveryTarget.ps1 b/src/private/recovery/Complete-RecoveryTarget.ps1 index 0f95b0a..9d722c4 100644 --- a/src/private/recovery/Complete-RecoveryTarget.ps1 +++ b/src/private/recovery/Complete-RecoveryTarget.ps1 @@ -1,6 +1,12 @@ function Complete-RecoveryTarget { - param([object]$Journal, [object]$Operation) - if (-not (Test-RecoveryTargetComplete $Journal)) { return $false } + param( + [object]$Journal, + [object]$Operation + ) + + if (-not (Test-RecoveryTargetComplete $Journal)) { + return $false + } $Journal.RecoveryStatus = 'TargetVerified' Write-RecoveryJournalState $Journal 'Completed' $Operation.RecoveryStatus = 'TargetVerified' diff --git a/src/private/recovery/ConvertTo-RecoveryJournal.ps1 b/src/private/recovery/ConvertTo-RecoveryJournal.ps1 index 14820a7..8222a7b 100644 --- a/src/private/recovery/ConvertTo-RecoveryJournal.ps1 +++ b/src/private/recovery/ConvertTo-RecoveryJournal.ps1 @@ -1,12 +1,10 @@ function ConvertTo-RecoveryJournal { - param([object]$Operation, [object[]]$StagedTree) - $plan = $Operation.Plan - [pscustomobject]@{ - SchemaVersion = 1; ModuleName = $plan.ModuleName; Scope = $plan.Scope; DestinationRoot = $plan.DestinationRoot - ModuleGuid = "$($plan.ModuleGuid)"; PreviousVersion = $plan.InstalledVersion; TargetVersion = $plan.TargetVersion - TargetNumericVersion = $plan.TargetNumericVersion; TargetPath = $plan.TargetInstalledPath - JournalPath = $Operation.JournalPath; StageRoot = $Operation.StageRoot; StagedPath = $Operation.StagedPath - BackupPath = $Operation.BackupPath; Reinstall = $Operation.Reinstall - State = 'Preparation'; RecoveryStatus = 'NotRequired'; StagedTree = @($StagedTree); BackupTree = @() - } + param( + [object]$Operation, + [object[]]$StagedTree + ) + + $journal = [pscustomobject](ConvertTo-RecoveryJournalIdentity $Operation.Plan) + $journal | Add-Member -NotePropertyMembers (ConvertTo-RecoveryJournalProgress $Operation $StagedTree) + $journal } diff --git a/src/private/recovery/ConvertTo-RecoveryJournalIdentity.ps1 b/src/private/recovery/ConvertTo-RecoveryJournalIdentity.ps1 new file mode 100644 index 0000000..268806a --- /dev/null +++ b/src/private/recovery/ConvertTo-RecoveryJournalIdentity.ps1 @@ -0,0 +1,16 @@ +function ConvertTo-RecoveryJournalIdentity { + param([object]$Plan) + + [ordered]@{ + SchemaVersion = 1 + TransactionId = [guid]::NewGuid().ToString('N') + ModuleName = $Plan.ModuleName + Scope = $Plan.Scope + DestinationRoot = $Plan.DestinationRoot + ModuleGuid = "$($Plan.ModuleGuid)" + PreviousVersion = $Plan.InstalledVersion + TargetVersion = $Plan.TargetVersion + TargetNumericVersion = $Plan.TargetNumericVersion + TargetPath = $Plan.TargetInstalledPath + } +} diff --git a/src/private/recovery/ConvertTo-RecoveryJournalProgress.ps1 b/src/private/recovery/ConvertTo-RecoveryJournalProgress.ps1 new file mode 100644 index 0000000..370b756 --- /dev/null +++ b/src/private/recovery/ConvertTo-RecoveryJournalProgress.ps1 @@ -0,0 +1,18 @@ +function ConvertTo-RecoveryJournalProgress { + param( + [object]$Operation, + [object[]]$StagedTree + ) + + [ordered]@{ + JournalPath = $Operation.JournalPath + StageRoot = $Operation.StageRoot + StagedPath = $Operation.StagedPath + BackupPath = $Operation.BackupPath + Reinstall = $Operation.Reinstall + State = 'Preparation' + RecoveryStatus = 'NotRequired' + StagedTree = @($StagedTree) + BackupTree = @() + } +} diff --git a/src/private/recovery/Get-CompletedCleanupPath.ps1 b/src/private/recovery/Get-CompletedCleanupPath.ps1 new file mode 100644 index 0000000..0566c17 --- /dev/null +++ b/src/private/recovery/Get-CompletedCleanupPath.ps1 @@ -0,0 +1,12 @@ +function Get-CompletedCleanupPath { + param( + [object]$Operation, + [object]$Journal + ) + + $Operation.StageRoot + $Operation.BackupPath + if ($Journal.PSObject.Properties['TransactionId']) { + Get-NativeProviderRoot $Operation $Journal + } +} diff --git a/src/private/recovery/Get-ExpectedRecoveryJournalIdentity.ps1 b/src/private/recovery/Get-ExpectedRecoveryJournalIdentity.ps1 new file mode 100644 index 0000000..084868a --- /dev/null +++ b/src/private/recovery/Get-ExpectedRecoveryJournalIdentity.ps1 @@ -0,0 +1,15 @@ +function Get-ExpectedRecoveryJournalIdentity { + param( + [object]$Plan, + [object]$Version + ) + + [pscustomobject]@{ + SchemaVersion = 1 + ModuleName = $Plan.ModuleName + ModuleGuid = "$($Plan.ModuleGuid)" + Scope = $Plan.Scope + DestinationRoot = $Plan.DestinationRoot + TargetNumericVersion = $Version.NumericVersion + } +} diff --git a/src/private/recovery/Get-RecoveryInstallationObservation.ps1 b/src/private/recovery/Get-RecoveryInstallationObservation.ps1 index f51d57f..a0c004b 100644 --- a/src/private/recovery/Get-RecoveryInstallationObservation.ps1 +++ b/src/private/recovery/Get-RecoveryInstallationObservation.ps1 @@ -1,12 +1,15 @@ function Get-RecoveryInstallationObservation { param([object]$Journal) - if (-not $Journal) { return } - if ($Journal.State -in @('Completed', 'RecoveryCompleted', 'Preparation')) { return } - if (-not $Journal.Reinstall) { return } - $backup = Get-VerifiedRecoveryBackup $Journal 'PMU.RecoveryRequired' - [pscustomobject]@{ - FullVersion = $backup.Version.FullVersion; NumericVersion = $backup.Version.NumericVersion; Prerelease = $backup.Version.Prerelease - Scope = $Journal.Scope; Root = $Journal.DestinationRoot - ManifestPath = Join-Path $Journal.TargetPath "$($Journal.ModuleName).psd1"; Guid = $backup.Guid + if (-not $Journal) { + return + } + if ($Journal.State -in @('Completed', 'RecoveryCompleted', 'Preparation')) { + return } + if (-not $Journal.Reinstall) { + return + } + $backup = Get-VerifiedRecoveryBackup $Journal 'PMU.RecoveryRequired' + $manifestPath = Join-Path $Journal.TargetPath "$($Journal.ModuleName).psd1" + ConvertTo-InstallationObservation $backup $Journal.Scope $Journal.DestinationRoot $manifestPath } diff --git a/src/private/recovery/Get-RecoveryJournalField.ps1 b/src/private/recovery/Get-RecoveryJournalField.ps1 new file mode 100644 index 0000000..1560c44 --- /dev/null +++ b/src/private/recovery/Get-RecoveryJournalField.ps1 @@ -0,0 +1,9 @@ +function Get-RecoveryJournalField { + param([object]$Journal) + $fields = 'SchemaVersion ModuleName Scope DestinationRoot ModuleGuid PreviousVersion TargetVersion TargetNumericVersion TargetPath JournalPath StageRoot StagedPath BackupPath Reinstall State RecoveryStatus StagedTree BackupTree'.Split(' ') + if ($Journal.PSObject.Properties['TransactionId']) { + Assert-RecoveryTransactionId $Journal.TransactionId + $fields += 'TransactionId' + } + $fields +} diff --git a/src/private/recovery/Get-SurvivingManifestData.ps1 b/src/private/recovery/Get-SurvivingManifestData.ps1 index 403f01a..b8312a4 100644 --- a/src/private/recovery/Get-SurvivingManifestData.ps1 +++ b/src/private/recovery/Get-SurvivingManifestData.ps1 @@ -1,6 +1,11 @@ function Get-SurvivingManifestData { param([string]$Path) - if (-not (Test-Path -LiteralPath $Path)) { return @{} } - try { Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop } - catch { @{} } # A provider interruption can leave a truncated manifest in a journal-owned path. + if (-not (Test-Path -LiteralPath $Path)) { + return @{} + } + try { + Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop + } catch { + @{} + } # A provider interruption can leave a truncated manifest in a journal-owned path. } diff --git a/src/private/recovery/Get-VerifiedRecoveryBackup.ps1 b/src/private/recovery/Get-VerifiedRecoveryBackup.ps1 index 7219fbf..21912d0 100644 --- a/src/private/recovery/Get-VerifiedRecoveryBackup.ps1 +++ b/src/private/recovery/Get-VerifiedRecoveryBackup.ps1 @@ -1,5 +1,9 @@ function Get-VerifiedRecoveryBackup { - param([object]$Journal, [string]$ErrorId = 'PMU.RecoveryFailed') + param( + [object]$Journal, + [string]$ErrorId = 'PMU.RecoveryFailed' + ) + $manifest = Join-Path $Journal.BackupPath "$($Journal.ModuleName).psd1" $identity = Get-ManifestIdentity $manifest if ($identity.Guid -ne [guid]$Journal.ModuleGuid -or $identity.Version.FullVersion -cne $Journal.PreviousVersion) { diff --git a/src/private/recovery/Invoke-RecoveryRestoration.ps1 b/src/private/recovery/Invoke-RecoveryRestoration.ps1 index f3f776e..9985309 100644 --- a/src/private/recovery/Invoke-RecoveryRestoration.ps1 +++ b/src/private/recovery/Invoke-RecoveryRestoration.ps1 @@ -1,8 +1,16 @@ function Invoke-RecoveryRestoration { - param([object]$Journal, [object]$Operation) + param( + [object]$Journal, + [object]$Operation + ) + try { if ($Journal.State -in @('Preparation', 'BackupReady')) { Write-RecoveryJournalState $Journal 'RecoveryCompleted' - } else { Restore-InstallationBackup $Journal $Operation } - } catch { Write-PMUError 'PMU.RecoveryFailed' 'Recovery could not be verified. The journal and surviving backup were preserved.' } + } else { + Restore-InstallationBackup $Journal $Operation + } + } catch { + Write-PMUError 'PMU.RecoveryFailed' 'Recovery could not be verified. The journal and surviving backup were preserved.' + } } diff --git a/src/private/recovery/Read-RecoveryJournal.ps1 b/src/private/recovery/Read-RecoveryJournal.ps1 index acf2c4f..f48aa9c 100644 --- a/src/private/recovery/Read-RecoveryJournal.ps1 +++ b/src/private/recovery/Read-RecoveryJournal.ps1 @@ -1,11 +1,14 @@ function Read-RecoveryJournal { param([object]$Operation) - if (-not (Test-Path -LiteralPath $Operation.JournalPath)) { return $null } + if (-not (Test-Path -LiteralPath $Operation.JournalPath)) { + return $null + } try { $null = Assert-SafePath $Operation.JournalPath $journal = Get-Content -LiteralPath $Operation.JournalPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop Assert-RecoveryJournal $journal $Operation return $journal + } catch { + Write-PMUError 'PMU.RecoveryRequired' 'The journal or its paths are unsafe. Preserve PMU data for manual inspection.' } - catch { Write-PMUError 'PMU.RecoveryRequired' 'The journal or its paths are unsafe. Preserve PMU data for manual inspection.' } } diff --git a/src/private/recovery/Repair-UnfinishedInstallation.ps1 b/src/private/recovery/Repair-UnfinishedInstallation.ps1 index ee33dd4..bd9ea6a 100644 --- a/src/private/recovery/Repair-UnfinishedInstallation.ps1 +++ b/src/private/recovery/Repair-UnfinishedInstallation.ps1 @@ -1,9 +1,13 @@ function Repair-UnfinishedInstallation { param([object]$Operation) $journal = Read-RecoveryJournal $Operation - if (-not $journal -or $journal.State -in @('Completed', 'RecoveryCompleted')) { return } + if (-not $journal -or $journal.State -in @('Completed', 'RecoveryCompleted')) { + return + } Write-InstallationPhase $Operation.Plan 'Recovering' - if (Complete-RecoveryTarget $journal $Operation) { return } + if (Complete-RecoveryTarget $journal $Operation) { + return + } Invoke-RecoveryRestoration $journal $Operation $null = (Get-LivePlanRegistry).Remove($Operation.Plan) Write-PMUError 'PMU.PlanStale' 'An unfinished operation was recovered. Obtain a fresh plan before another update.' diff --git a/src/private/recovery/Restore-InstallationBackup.ps1 b/src/private/recovery/Restore-InstallationBackup.ps1 index cc2c787..299093c 100644 --- a/src/private/recovery/Restore-InstallationBackup.ps1 +++ b/src/private/recovery/Restore-InstallationBackup.ps1 @@ -1,7 +1,13 @@ function Restore-InstallationBackup { - param([object]$Journal, [object]$Operation) + param( + [object]$Journal, + [object]$Operation + ) + Assert-RecoveryJournal $Journal $Operation - if ($Journal.Reinstall) { $null = Get-VerifiedRecoveryBackup $Journal } + if ($Journal.Reinstall) { + $null = Get-VerifiedRecoveryBackup $Journal + } Clear-ControlledTarget $Journal if ($Journal.Reinstall) { Copy-Item -LiteralPath $Journal.BackupPath -Destination $Journal.TargetPath -Recurse -ErrorAction Stop diff --git a/src/private/recovery/Test-RecoveryTargetComplete.ps1 b/src/private/recovery/Test-RecoveryTargetComplete.ps1 index ce7f357..cc71783 100644 --- a/src/private/recovery/Test-RecoveryTargetComplete.ps1 +++ b/src/private/recovery/Test-RecoveryTargetComplete.ps1 @@ -2,9 +2,12 @@ function Test-RecoveryTargetComplete { param([object]$Journal) try { $identity = Get-ManifestIdentity (Join-Path $Journal.TargetPath "$($Journal.ModuleName).psd1") - if ($identity.Guid -ne [guid]$Journal.ModuleGuid -or $identity.Version.FullVersion -cne $Journal.TargetVersion) { return $false } + if ($identity.Guid -ne [guid]$Journal.ModuleGuid -or $identity.Version.FullVersion -cne $Journal.TargetVersion) { + return $false + } Assert-FileTreeEqual @($Journal.StagedTree) @(Get-FileTreeInventory $Journal.TargetPath) return $true + } catch { + return $false } - catch { return $false } } diff --git a/src/private/recovery/Write-DurableJournal.ps1 b/src/private/recovery/Write-DurableJournal.ps1 index f033f7c..3ac3242 100644 --- a/src/private/recovery/Write-DurableJournal.ps1 +++ b/src/private/recovery/Write-DurableJournal.ps1 @@ -5,7 +5,11 @@ function Write-DurableJournal { $null = Assert-SafePath $temporary $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Journal -Depth 12 -Compress)) $stream = [IO.File]::Open($temporary, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) - try { $stream.Write($bytes); $stream.Flush($true) } - finally { $stream.Dispose() } + try { + $stream.Write($bytes) + $stream.Flush($true) + } finally { + $stream.Dispose() + } [IO.File]::Move($temporary, $path, $true) } diff --git a/src/private/recovery/Write-RecoveryJournalState.ps1 b/src/private/recovery/Write-RecoveryJournalState.ps1 index 1808a01..636b2fa 100644 --- a/src/private/recovery/Write-RecoveryJournalState.ps1 +++ b/src/private/recovery/Write-RecoveryJournalState.ps1 @@ -1,14 +1,21 @@ function Write-RecoveryJournalState { - param([object]$Journal, [string]$State) + param( + [object]$Journal, + [string]$State + ) + $transitions = @{ Preparation = @('Preparation', 'BackupReady', 'ReplacementInProgress', 'RecoveryCompleted') BackupReady = @('BackupReady', 'ReplacementInProgress', 'RecoveryCompleted') ReplacementInProgress = @('ReplacementInProgress', 'Verification', 'RecoveryRequired', 'RecoveryCompleted', 'Completed') Verification = @('Verification', 'Completed', 'RecoveryRequired', 'RecoveryCompleted') RecoveryRequired = @('RecoveryRequired', 'RecoveryCompleted', 'Completed') - Completed = @('Completed'); RecoveryCompleted = @('RecoveryCompleted') + Completed = @('Completed') + RecoveryCompleted = @('RecoveryCompleted') + } + if ($State -notin $transitions[$Journal.State]) { + Write-PMUError 'PMU.RecoveryRequired' 'Invalid recovery journal state transition.' } - if ($State -notin $transitions[$Journal.State]) { Write-PMUError 'PMU.RecoveryRequired' 'Invalid recovery journal state transition.' } $Journal.State = $State Write-DurableJournal $Journal } diff --git a/src/private/repository/Assert-OwnedRepositoryConfiguration.ps1 b/src/private/repository/Assert-OwnedRepositoryConfiguration.ps1 index c259fb7..0b5dc7e 100644 --- a/src/private/repository/Assert-OwnedRepositoryConfiguration.ps1 +++ b/src/private/repository/Assert-OwnedRepositoryConfiguration.ps1 @@ -1,8 +1,15 @@ function Assert-OwnedRepositoryConfiguration { - param([object]$Registration, [object]$Receipt) + param( + [object]$Registration, + [object]$Receipt + ) + $actual = [pscustomobject]@{ - RegistrationName = $Registration.Name; UriHash = Get-RepositoryUriHash ([uri]$Registration.Uri) - ApiVersion = "$($Registration.ApiVersion)"; Trusted = [bool]$Registration.Trusted; Priority = $Registration.Priority + RegistrationName = $Registration.Name + UriHash = Get-RepositoryUriHash ([uri]$Registration.Uri) + ApiVersion = "$($Registration.ApiVersion)" + Trusted = [bool]$Registration.Trusted + Priority = $Registration.Priority } Assert-MatchingProperty $actual $Receipt @('RegistrationName', 'UriHash', 'ApiVersion', 'Trusted', 'Priority') 'PMU.RepositoryRecoveryRequired' } diff --git a/src/private/repository/Assert-RepositoryBinding.ps1 b/src/private/repository/Assert-RepositoryBinding.ps1 index cacf135..fd661f0 100644 --- a/src/private/repository/Assert-RepositoryBinding.ps1 +++ b/src/private/repository/Assert-RepositoryBinding.ps1 @@ -1,12 +1,15 @@ function Assert-RepositoryBinding { - param([object]$Plan, [string]$TransportName) - $name = $Plan.RepositoryName - if ($TransportName) { $name = $TransportName } - if (-not $name) { $null = Assert-RepositoryUri ([uri]$Plan.RepositoryUri); return } - $source = Get-RepositorySnapshot $name ([uri]$Plan.RepositoryUri) - $expected = [pscustomobject]@{ - Name = $name; Uri = $Plan.RepositoryUri - ApiVersion = $Plan.RepositoryApiVersion; Trusted = $Plan.RepositoryTrusted + param( + [object]$Plan, + [string]$TransportName + ) + + $name = $TransportName ? $TransportName : $Plan.RepositoryName + if (-not $name) { + $null = Assert-RepositoryUri ([uri]$Plan.RepositoryUri) + return } + $source = Get-RepositorySnapshot $name ([uri]$Plan.RepositoryUri) + $expected = ConvertTo-PlanRepositorySource $Plan $name Assert-MatchingProperty $source $expected @('Name', 'Uri', 'ApiVersion', 'Trusted') 'PMU.RepositoryChanged' } diff --git a/src/private/repository/Assert-RepositoryReceipt.ps1 b/src/private/repository/Assert-RepositoryReceipt.ps1 index ee73d6d..ec70a02 100644 --- a/src/private/repository/Assert-RepositoryReceipt.ps1 +++ b/src/private/repository/Assert-RepositoryReceipt.ps1 @@ -5,5 +5,8 @@ function Assert-RepositoryReceipt { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt schema is invalid.' } Assert-RepositoryReceiptIdentity $Receipt - Assert-MatchingProperty $Receipt ([pscustomobject]@{ Trusted = $false; Priority = 50 }) @('Trusted', 'Priority') 'PMU.RepositoryRecoveryRequired' + Assert-MatchingProperty $Receipt ([pscustomobject]@{ + Trusted = $false + Priority = 50 + }) @('Trusted', 'Priority') 'PMU.RepositoryRecoveryRequired' } diff --git a/src/private/repository/Assert-RepositoryReceiptIdentity.ps1 b/src/private/repository/Assert-RepositoryReceiptIdentity.ps1 index 1b2913a..8e1d517 100644 --- a/src/private/repository/Assert-RepositoryReceiptIdentity.ps1 +++ b/src/private/repository/Assert-RepositoryReceiptIdentity.ps1 @@ -9,5 +9,7 @@ function Assert-RepositoryReceiptIdentity { "$($Receipt.UriHash)" -cmatch '^[A-F0-9]{64}$' "$($Receipt.ApiVersion)" -cin @('Unknown', 'V2', 'V3', 'Local', 'NugetServer', 'ContainerRegistry') ) - if ($false -in $values) { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt identity is invalid.' } + if ($false -in $values) { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt identity is invalid.' + } } diff --git a/src/private/repository/Assert-RepositoryUri.ps1 b/src/private/repository/Assert-RepositoryUri.ps1 index d840fa2..daef271 100644 --- a/src/private/repository/Assert-RepositoryUri.ps1 +++ b/src/private/repository/Assert-RepositoryUri.ps1 @@ -1,6 +1,8 @@ function Assert-RepositoryUri { param([uri]$Uri) - if (-not $Uri.IsAbsoluteUri) { Write-PMUError 'PMU.InvalidRequest' 'Repository endpoints must be absolute.' } + if (-not $Uri.IsAbsoluteUri) { + Write-PMUError 'PMU.InvalidRequest' 'Repository endpoints must be absolute.' + } if ("$($Uri.UserInfo)$($Uri.Query)$($Uri.Fragment)") { Write-PMUError 'PMU.InvalidRequest' 'Repository endpoints must be absolute and must not contain credentials, queries, or fragments.' } diff --git a/src/private/repository/Clear-OwnedRepositoryReceipt.ps1 b/src/private/repository/Clear-OwnedRepositoryReceipt.ps1 index 6aa11ce..6463824 100644 --- a/src/private/repository/Clear-OwnedRepositoryReceipt.ps1 +++ b/src/private/repository/Clear-OwnedRepositoryReceipt.ps1 @@ -1,11 +1,16 @@ function Clear-OwnedRepositoryReceipt { param([object]$Entry) $registered = @(Get-RepositoryRegistration $Entry.Receipt.RegistrationName) - if ($registered.Count -gt 1) { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Owned registration is ambiguous.' } + if ($registered.Count -gt 1) { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Owned registration is ambiguous.' + } if ($registered.Count) { Assert-OwnedRepositoryConfiguration $registered[0] $Entry.Receipt - try { Microsoft.PowerShell.PSResourceGet\Unregister-PSResourceRepository -Name $Entry.Receipt.RegistrationName -Confirm:$false -ErrorAction Stop } - catch { Write-ProviderError $_ 'PMU.RepositoryCleanupFailed' } + try { + Microsoft.PowerShell.PSResourceGet\Unregister-PSResourceRepository -Name $Entry.Receipt.RegistrationName -Confirm:$false -ErrorAction Stop + } catch { + Write-ProviderError $_ 'PMU.RepositoryCleanupFailed' + } } Remove-Item -LiteralPath (Assert-SafePath $Entry.Path) -ErrorAction Stop } diff --git a/src/private/repository/Close-OwnedRepositoryTransport.ps1 b/src/private/repository/Close-OwnedRepositoryTransport.ps1 index 626b41c..66381e0 100644 --- a/src/private/repository/Close-OwnedRepositoryTransport.ps1 +++ b/src/private/repository/Close-OwnedRepositoryTransport.ps1 @@ -5,8 +5,14 @@ function Close-OwnedRepositoryTransport { try { $receipt = Read-RepositoryReceipt $Lease.ReceiptPath $root Assert-MatchingProperty $receipt $Lease.Receipt @('OperationId', 'RegistrationName', 'OwnerPid', 'OwnerStartedUtcTicks', 'UriHash') 'PMU.RepositoryRecoveryRequired' - if ($receipt.OwnerPid -ne $PID) { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'A different operation owns this registration.' } - Clear-OwnedRepositoryReceipt ([pscustomobject]@{ Path = $Lease.ReceiptPath; Receipt = $receipt }) + if ($receipt.OwnerPid -ne $PID) { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'A different operation owns this registration.' + } + Clear-OwnedRepositoryReceipt ([pscustomobject]@{ + Path = $Lease.ReceiptPath + Receipt = $receipt + }) + } finally { + $lock.Dispose() } - finally { $lock.Dispose() } } diff --git a/src/private/repository/Close-RepositoryTransport.ps1 b/src/private/repository/Close-RepositoryTransport.ps1 index 92c87a6..82e3596 100644 --- a/src/private/repository/Close-RepositoryTransport.ps1 +++ b/src/private/repository/Close-RepositoryTransport.ps1 @@ -1,6 +1,15 @@ function Close-RepositoryTransport { - param([object]$Lease, [System.Management.Automation.ErrorRecord]$Original) - if (-not $Lease.ReceiptPath) { return } - try { Close-OwnedRepositoryTransport $Lease } - catch { Write-RepositoryCleanupFailure $Original } + param( + [object]$Lease, + [System.Management.Automation.ErrorRecord]$Original + ) + + if (-not $Lease.ReceiptPath) { + return + } + try { + Close-OwnedRepositoryTransport $Lease + } catch { + Write-RepositoryCleanupFailure $Original + } } diff --git a/src/private/repository/Complete-OwnedRepositoryBinding.ps1 b/src/private/repository/Complete-OwnedRepositoryBinding.ps1 index d06d8b6..418152b 100644 --- a/src/private/repository/Complete-OwnedRepositoryBinding.ps1 +++ b/src/private/repository/Complete-OwnedRepositoryBinding.ps1 @@ -1,9 +1,18 @@ function Complete-OwnedRepositoryBinding { - param([object]$Lease, [object]$Registration) - if ($Lease.Receipt.ApiVersion -ne 'Unknown') { Assert-OwnedRepositoryConfiguration $Registration $Lease.Receipt; return } + param( + [object]$Lease, + [object]$Registration + ) + + if ($Lease.Receipt.ApiVersion -ne 'Unknown') { + Assert-OwnedRepositoryConfiguration $Registration $Lease.Receipt + return + } $bound = $Lease.Receipt.PSObject.Copy() $bound.ApiVersion = "$($Registration.ApiVersion)" - if ($bound.ApiVersion -eq 'Unknown') { Write-PMUError 'PMU.RepositoryUnavailable' 'The provider did not resolve its repository API.' } + if ($bound.ApiVersion -eq 'Unknown') { + Write-PMUError 'PMU.RepositoryUnavailable' 'The provider did not resolve its repository API.' + } Assert-RepositoryReceipt $bound Assert-OwnedRepositoryConfiguration $Registration $bound $Lease.Receipt = $bound diff --git a/src/private/repository/Complete-OwnedRepositoryRegistration.ps1 b/src/private/repository/Complete-OwnedRepositoryRegistration.ps1 new file mode 100644 index 0000000..6cde060 --- /dev/null +++ b/src/private/repository/Complete-OwnedRepositoryRegistration.ps1 @@ -0,0 +1,15 @@ +function Complete-OwnedRepositoryRegistration { + param( + [object]$Lease, + [object[]]$Created + ) + if ($Created.Count -ne 1) { + Write-PMUError 'PMU.RepositoryUnavailable' 'Transport registration did not return one provider identity.' + } + Complete-OwnedRepositoryBinding $Lease $Created[0] + $registered = @(Get-RepositoryRegistration $Lease.Name) + if ($registered.Count -ne 1) { + Write-PMUError 'PMU.RepositoryUnavailable' 'Transport registration could not be verified.' + } + Assert-OwnedRepositoryConfiguration $registered[0] $Lease.Receipt +} diff --git a/src/private/repository/ConvertTo-PlanRepositorySource.ps1 b/src/private/repository/ConvertTo-PlanRepositorySource.ps1 new file mode 100644 index 0000000..c70c865 --- /dev/null +++ b/src/private/repository/ConvertTo-PlanRepositorySource.ps1 @@ -0,0 +1,13 @@ +function ConvertTo-PlanRepositorySource { + param( + [object]$Plan, + [string]$TransportName + ) + + [pscustomobject]@{ + Name = $TransportName ? $TransportName : $Plan.RepositoryName + Uri = $Plan.RepositoryUri + ApiVersion = $Plan.RepositoryApiVersion + Trusted = $Plan.RepositoryTrusted + } +} diff --git a/src/private/repository/ConvertTo-RepositoryReceipt.ps1 b/src/private/repository/ConvertTo-RepositoryReceipt.ps1 index f9427fa..401824c 100644 --- a/src/private/repository/ConvertTo-RepositoryReceipt.ps1 +++ b/src/private/repository/ConvertTo-RepositoryReceipt.ps1 @@ -2,9 +2,14 @@ function ConvertTo-RepositoryReceipt { param([object]$Source) $operationId = [guid]::NewGuid().ToString('N') [pscustomobject]@{ - SchemaVersion = 1; OperationId = $operationId; RegistrationName = "pmu-uri-$operationId" - OwnerPid = $PID; OwnerStartedUtcTicks = Get-RepositoryProcessStart $PID + SchemaVersion = 1 + OperationId = $operationId + RegistrationName = "pmu-uri-$operationId" + OwnerPid = $PID + OwnerStartedUtcTicks = Get-RepositoryProcessStart $PID UriHash = Get-RepositoryUriHash ([uri]$Source.Uri) - ApiVersion = "$($Source.ApiVersion)"; Trusted = $false; Priority = 50 + ApiVersion = "$($Source.ApiVersion)" + Trusted = $false + Priority = 50 } } diff --git a/src/private/repository/Get-BoundedRepositoryReceiptPath.ps1 b/src/private/repository/Get-BoundedRepositoryReceiptPath.ps1 new file mode 100644 index 0000000..5599b43 --- /dev/null +++ b/src/private/repository/Get-BoundedRepositoryReceiptPath.ps1 @@ -0,0 +1,15 @@ +function Get-BoundedRepositoryReceiptPath { + param( + [string]$Path, + [string]$Root + ) + + $safe = Assert-SafePath $Path + if ([IO.Path]::GetDirectoryName($safe) -cne (Assert-SafePath $Root)) { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt path is invalid.' + } + if ((Get-Item -LiteralPath $safe).Length -gt 8192) { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt is oversized.' + } + $safe +} diff --git a/src/private/repository/Get-ConsumerRepositoryByUri.ps1 b/src/private/repository/Get-ConsumerRepositoryByUri.ps1 new file mode 100644 index 0000000..92f2740 --- /dev/null +++ b/src/private/repository/Get-ConsumerRepositoryByUri.ps1 @@ -0,0 +1,14 @@ +function Get-ConsumerRepositoryByUri { + param([string]$Uri) + try { + $repositories = @(Microsoft.PowerShell.PSResourceGet\Get-PSResourceRepository -ErrorAction Stop) + } catch { + Write-ProviderError $_ 'PMU.RepositoryUnavailable' + } + $owned = @(Get-OwnedRepositoryName) + $selected = @($repositories | Where-Object { $_.Name -cnotin $owned -and ([uri]$_.Uri).AbsoluteUri.TrimEnd('/') -ceq $Uri }) + if ($selected.Count -gt 1) { + Write-PMUError 'PMU.RepositoryUnavailable' 'RepositoryUri matches multiple consumer registrations.' + } + $selected +} diff --git a/src/private/repository/Get-OperationRepositoryName.ps1 b/src/private/repository/Get-OperationRepositoryName.ps1 index 0b91072..18b8467 100644 --- a/src/private/repository/Get-OperationRepositoryName.ps1 +++ b/src/private/repository/Get-OperationRepositoryName.ps1 @@ -1,5 +1,7 @@ function Get-OperationRepositoryName { param([object]$Operation) - if ($Operation.RepositoryName) { return $Operation.RepositoryName } + if ($Operation.RepositoryName) { + return $Operation.RepositoryName + } $Operation.Plan.RepositoryName } diff --git a/src/private/repository/Get-OwnedRepositoryName.ps1 b/src/private/repository/Get-OwnedRepositoryName.ps1 index 80b043c..65e47a6 100644 --- a/src/private/repository/Get-OwnedRepositoryName.ps1 +++ b/src/private/repository/Get-OwnedRepositoryName.ps1 @@ -2,7 +2,9 @@ function Get-OwnedRepositoryName { $root = Resolve-RepositoryTransportRoot foreach ($entry in Get-RepositoryReceiptEntry $root) { $registered = @(Get-RepositoryRegistration $entry.Receipt.RegistrationName) - if (-not $registered.Count) { continue } + if (-not $registered.Count) { + continue + } Assert-OwnedRepositoryConfiguration $registered[0] $entry.Receipt $entry.Receipt.RegistrationName } diff --git a/src/private/repository/Get-OwnedRepositoryRegisterParameter.ps1 b/src/private/repository/Get-OwnedRepositoryRegisterParameter.ps1 new file mode 100644 index 0000000..9e184bd --- /dev/null +++ b/src/private/repository/Get-OwnedRepositoryRegisterParameter.ps1 @@ -0,0 +1,13 @@ +function Get-OwnedRepositoryRegisterParameter { + param([object]$Lease) + $parameters = @{ + Name = $Lease.Name + Uri = $Lease.Source.Uri + Priority = 50 + ErrorAction = 'Stop' + } + if ($Lease.Receipt.ApiVersion -ne 'Unknown') { + $parameters.ApiVersion = $Lease.Receipt.ApiVersion + } + $parameters +} diff --git a/src/private/repository/Get-RepositoryReceiptEntry.ps1 b/src/private/repository/Get-RepositoryReceiptEntry.ps1 index da0bb9f..dd1851a 100644 --- a/src/private/repository/Get-RepositoryReceiptEntry.ps1 +++ b/src/private/repository/Get-RepositoryReceiptEntry.ps1 @@ -1,9 +1,14 @@ function Get-RepositoryReceiptEntry { param([string]$Root) $null = Assert-SafePath $Root - if (-not (Test-Path -LiteralPath $Root)) { return } + if (-not (Test-Path -LiteralPath $Root)) { + return + } Initialize-ControlledDirectory $Root foreach ($file in Get-ChildItem -LiteralPath $Root -Filter '*.json' -File) { - [pscustomobject]@{ Path = $file.FullName; Receipt = Read-RepositoryReceipt $file.FullName $Root } + [pscustomobject]@{ + Path = $file.FullName + Receipt = Read-RepositoryReceipt $file.FullName $Root + } } } diff --git a/src/private/repository/Get-RepositoryReceiptStreamOption.ps1 b/src/private/repository/Get-RepositoryReceiptStreamOption.ps1 new file mode 100644 index 0000000..a804cd6 --- /dev/null +++ b/src/private/repository/Get-RepositoryReceiptStreamOption.ps1 @@ -0,0 +1,10 @@ +function Get-RepositoryReceiptStreamOption { + $options = [IO.FileStreamOptions]::new() + $options.Mode = [IO.FileMode]::CreateNew + $options.Access = [IO.FileAccess]::Write + $options.Share = [IO.FileShare]::None + if (-not $IsWindows) { + $options.UnixCreateMode = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite + } + $options +} diff --git a/src/private/repository/Get-RepositoryRegistration.ps1 b/src/private/repository/Get-RepositoryRegistration.ps1 index 19cb8f8..4932f01 100644 --- a/src/private/repository/Get-RepositoryRegistration.ps1 +++ b/src/private/repository/Get-RepositoryRegistration.ps1 @@ -1,5 +1,8 @@ function Get-RepositoryRegistration { param([string]$Name) - try { @(Microsoft.PowerShell.PSResourceGet\Get-PSResourceRepository -ErrorAction Stop | Where-Object Name -CEQ $Name) } - catch { Write-ProviderError $_ 'PMU.RepositoryUnavailable' } + try { + @(Microsoft.PowerShell.PSResourceGet\Get-PSResourceRepository -ErrorAction Stop | Where-Object Name -CEQ $Name) + } catch { + Write-ProviderError $_ 'PMU.RepositoryUnavailable' + } } diff --git a/src/private/repository/Get-RepositorySnapshot.ps1 b/src/private/repository/Get-RepositorySnapshot.ps1 index 1feb0b9..89613d5 100644 --- a/src/private/repository/Get-RepositorySnapshot.ps1 +++ b/src/private/repository/Get-RepositorySnapshot.ps1 @@ -1,11 +1,19 @@ function Get-RepositorySnapshot { - param([string]$Name, [uri]$ExpectedUri) + param( + [string]$Name, + [uri]$ExpectedUri + ) + Assert-RepositoryName $Name - try { $repositories = @(Microsoft.PowerShell.PSResourceGet\Get-PSResourceRepository -Name $Name -ErrorAction Stop) } - catch { Write-ProviderError $_ 'PMU.RepositoryUnavailable' } - if ($repositories.Count -ne 1) { Write-PMUError 'PMU.RepositoryUnavailable' 'Exactly one registered repository is required.' } - $repository = $repositories[0] + $repository = Get-SingleProviderRepository $Name $uri = Assert-RepositoryUri $repository.Uri - if ($ExpectedUri -and $uri -ne (Assert-RepositoryUri $ExpectedUri)) { Write-PMUError 'PMU.RepositoryChanged' 'Repository endpoint does not match policy.' } - [pscustomobject]@{ Name = $repository.Name; Uri = $uri; ApiVersion = "$($repository.ApiVersion)"; Trusted = [bool]$repository.Trusted } + if ($ExpectedUri -and $uri -ne (Assert-RepositoryUri $ExpectedUri)) { + Write-PMUError 'PMU.RepositoryChanged' 'Repository endpoint does not match policy.' + } + [pscustomobject]@{ + Name = $repository.Name + Uri = $uri + ApiVersion = "$($repository.ApiVersion)" + Trusted = [bool]$repository.Trusted + } } diff --git a/src/private/repository/Get-RepositorySnapshotByUri.ps1 b/src/private/repository/Get-RepositorySnapshotByUri.ps1 index 84792fe..f2aec15 100644 --- a/src/private/repository/Get-RepositorySnapshotByUri.ps1 +++ b/src/private/repository/Get-RepositorySnapshotByUri.ps1 @@ -1,12 +1,21 @@ function Get-RepositorySnapshotByUri { - param([uri]$RepositoryUri, [uri]$ExpectedUri) + param( + [uri]$RepositoryUri, + [uri]$ExpectedUri + ) + $uri = Assert-RepositoryUri $RepositoryUri - if ($ExpectedUri -and $uri -cne (Assert-RepositoryUri $ExpectedUri)) { Write-PMUError 'PMU.RepositoryChanged' 'Repository endpoint does not match policy.' } - try { $repositories = @(Microsoft.PowerShell.PSResourceGet\Get-PSResourceRepository -ErrorAction Stop) } - catch { Write-ProviderError $_ 'PMU.RepositoryUnavailable' } - $owned = @(Get-OwnedRepositoryName) - $selected = @($repositories | Where-Object { $_.Name -cnotin $owned -and ([uri]$_.Uri).AbsoluteUri.TrimEnd('/') -ceq $uri }) - if ($selected.Count -gt 1) { Write-PMUError 'PMU.RepositoryUnavailable' 'RepositoryUri matches multiple consumer registrations.' } - if (-not $selected.Count) { return [pscustomobject]@{ Name = $null; Uri = $uri; ApiVersion = 'Unknown'; Trusted = $false } } + if ($ExpectedUri -and $uri -cne (Assert-RepositoryUri $ExpectedUri)) { + Write-PMUError 'PMU.RepositoryChanged' 'Repository endpoint does not match policy.' + } + $selected = @(Get-ConsumerRepositoryByUri $uri) + if (-not $selected.Count) { + return [pscustomobject]@{ + Name = $null + Uri = $uri + ApiVersion = 'Unknown' + Trusted = $false + } + } Get-RepositorySnapshot $selected[0].Name ([uri]$uri) } diff --git a/src/private/repository/Get-SelectedRepository.ps1 b/src/private/repository/Get-SelectedRepository.ps1 index ad49814..f2bab2f 100644 --- a/src/private/repository/Get-SelectedRepository.ps1 +++ b/src/private/repository/Get-SelectedRepository.ps1 @@ -1,6 +1,14 @@ function Get-SelectedRepository { - param([object]$Selection, [uri]$ExpectedUri) - if ($Selection -is [string]) { return Get-RepositorySnapshot $Selection $ExpectedUri } - if ($Selection.Uri) { return Get-RepositorySnapshotByUri $Selection.Uri $ExpectedUri } + param( + [object]$Selection, + [uri]$ExpectedUri + ) + + if ($Selection -is [string]) { + return Get-RepositorySnapshot $Selection $ExpectedUri + } + if ($Selection.Uri) { + return Get-RepositorySnapshotByUri $Selection.Uri $ExpectedUri + } Get-RepositorySnapshot $Selection.Name $ExpectedUri } diff --git a/src/private/repository/Get-SingleProviderRepository.ps1 b/src/private/repository/Get-SingleProviderRepository.ps1 new file mode 100644 index 0000000..0cec393 --- /dev/null +++ b/src/private/repository/Get-SingleProviderRepository.ps1 @@ -0,0 +1,13 @@ +function Get-SingleProviderRepository { + param([string]$Name) + + try { + $repositories = @(Microsoft.PowerShell.PSResourceGet\Get-PSResourceRepository -Name $Name -ErrorAction Stop) + } catch { + Write-ProviderError $_ 'PMU.RepositoryUnavailable' + } + if ($repositories.Count -ne 1) { + Write-PMUError 'PMU.RepositoryUnavailable' 'Exactly one registered repository is required.' + } + $repositories[0] +} diff --git a/src/private/repository/Initialize-OwnedRepositoryLease.ps1 b/src/private/repository/Initialize-OwnedRepositoryLease.ps1 new file mode 100644 index 0000000..97346cd --- /dev/null +++ b/src/private/repository/Initialize-OwnedRepositoryLease.ps1 @@ -0,0 +1,16 @@ +function Initialize-OwnedRepositoryLease { + param( + [object]$Source, + [string]$Root + ) + + $receipt = ConvertTo-RepositoryReceipt $Source + $path = Write-RepositoryReceipt $receipt $Root + [pscustomobject]@{ + Name = $receipt.RegistrationName + ReceiptPath = $path + Root = $Root + Receipt = $receipt + Source = $Source + } +} diff --git a/src/private/repository/Invoke-OwnedRepositoryRegistration.ps1 b/src/private/repository/Invoke-OwnedRepositoryRegistration.ps1 index 7b26d18..a4a4566 100644 --- a/src/private/repository/Invoke-OwnedRepositoryRegistration.ps1 +++ b/src/private/repository/Invoke-OwnedRepositoryRegistration.ps1 @@ -1,14 +1,15 @@ function Invoke-OwnedRepositoryRegistration { - param([object]$Source, [string]$Root) - $receipt = ConvertTo-RepositoryReceipt $Source - $path = Write-RepositoryReceipt $receipt $Root - $lease = [pscustomobject]@{ Name = $receipt.RegistrationName; ReceiptPath = $path; Root = $Root; Receipt = $receipt; Source = $Source } - try { Register-OwnedRepository $lease } - catch { + param( + [object]$Source, + [string]$Root + ) + + $lease = Initialize-OwnedRepositoryLease $Source $Root + try { + Register-OwnedRepository $lease + } catch { $failure = $_ - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw $failure } - try { Clear-OwnedRepositoryReceipt ([pscustomobject]@{ Path = $path; Receipt = $lease.Receipt }) } - catch { Write-RepositoryCleanupFailure $failure } + Undo-OwnedRepositoryRegistration $lease $failure throw $failure } $lease diff --git a/src/private/repository/Open-RepositoryTransport.ps1 b/src/private/repository/Open-RepositoryTransport.ps1 index 23dfa70..1503cb8 100644 --- a/src/private/repository/Open-RepositoryTransport.ps1 +++ b/src/private/repository/Open-RepositoryTransport.ps1 @@ -1,12 +1,22 @@ function Open-RepositoryTransport { - param([object]$Source, [int]$TimeoutMilliseconds = 5000) + param( + [object]$Source, + [int]$TimeoutMilliseconds = 5000 + ) + $null = Assert-RepositoryUri ([uri]$Source.Uri) - if ($Source.Name) { return [pscustomobject]@{ Name = $Source.Name; ReceiptPath = $null } } + if ($Source.Name) { + return [pscustomobject]@{ + Name = $Source.Name + ReceiptPath = $null + } + } $root = Resolve-RepositoryTransportRoot $lock = Enter-RepositoryRegistryLock $root $TimeoutMilliseconds try { Repair-RepositoryReceipt $root Invoke-OwnedRepositoryRegistration $Source $root + } finally { + $lock.Dispose() } - finally { $lock.Dispose() } } diff --git a/src/private/repository/Read-RepositoryReceipt.ps1 b/src/private/repository/Read-RepositoryReceipt.ps1 index 4549bd6..421c241 100644 --- a/src/private/repository/Read-RepositoryReceipt.ps1 +++ b/src/private/repository/Read-RepositoryReceipt.ps1 @@ -1,11 +1,18 @@ function Read-RepositoryReceipt { - param([string]$Path, [string]$Root) - $safe = Assert-SafePath $Path - if ([IO.Path]::GetDirectoryName($safe) -cne (Assert-SafePath $Root)) { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt path is invalid.' } - if ((Get-Item -LiteralPath $safe).Length -gt 8192) { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt is oversized.' } - try { $receipt = Get-Content -LiteralPath $safe -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } - catch { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt is unreadable or incomplete.' } + param( + [string]$Path, + [string]$Root + ) + + $safe = Get-BoundedRepositoryReceiptPath $Path $Root + try { + $receipt = Get-Content -LiteralPath $safe -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt is unreadable or incomplete.' + } Assert-RepositoryReceipt $receipt - if ([IO.Path]::GetFileName($safe) -cne "$($receipt.OperationId).json") { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt name does not match its operation.' } + if ([IO.Path]::GetFileName($safe) -cne "$($receipt.OperationId).json") { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt name does not match its operation.' + } $receipt } diff --git a/src/private/repository/Register-OwnedRepository.ps1 b/src/private/repository/Register-OwnedRepository.ps1 index 8b34289..47a460e 100644 --- a/src/private/repository/Register-OwnedRepository.ps1 +++ b/src/private/repository/Register-OwnedRepository.ps1 @@ -4,15 +4,11 @@ function Register-OwnedRepository { Remove-Item -LiteralPath $Lease.ReceiptPath -ErrorAction Stop Write-PMUError 'PMU.RepositoryUnavailable' 'The random transport name already exists; it will not be overwritten.' } - $parameters = @{ Name = $Lease.Name; Uri = $Lease.Source.Uri; Priority = 50; ErrorAction = 'Stop' } - if ($Lease.Receipt.ApiVersion -ne 'Unknown') { $parameters.ApiVersion = $Lease.Receipt.ApiVersion } + $parameters = Get-OwnedRepositoryRegisterParameter $Lease try { $created = @(Microsoft.PowerShell.PSResourceGet\Register-PSResourceRepository @parameters -PassThru) + } catch { + Write-ProviderError $_ 'PMU.RepositoryUnavailable' } - catch { Write-ProviderError $_ 'PMU.RepositoryUnavailable' } - if ($created.Count -ne 1) { Write-PMUError 'PMU.RepositoryUnavailable' 'Transport registration did not return one provider identity.' } - Complete-OwnedRepositoryBinding $Lease $created[0] - $registered = @(Get-RepositoryRegistration $Lease.Name) - if ($registered.Count -ne 1) { Write-PMUError 'PMU.RepositoryUnavailable' 'Transport registration could not be verified.' } - Assert-OwnedRepositoryConfiguration $registered[0] $Lease.Receipt + Complete-OwnedRepositoryRegistration $Lease $created } diff --git a/src/private/repository/Repair-RepositoryReceipt.ps1 b/src/private/repository/Repair-RepositoryReceipt.ps1 index aa66640..ef78525 100644 --- a/src/private/repository/Repair-RepositoryReceipt.ps1 +++ b/src/private/repository/Repair-RepositoryReceipt.ps1 @@ -1,7 +1,9 @@ function Repair-RepositoryReceipt { param([string]$Root) foreach ($entry in Get-RepositoryReceiptEntry $Root) { - if (Test-RepositoryReceiptOwnerAlive $entry.Receipt) { continue } + if (Test-RepositoryReceiptOwnerAlive $entry.Receipt) { + continue + } Clear-OwnedRepositoryReceipt $entry } } diff --git a/src/private/repository/Test-RepositoryReceiptOwnerAlive.ps1 b/src/private/repository/Test-RepositoryReceiptOwnerAlive.ps1 index cfac88c..46a2763 100644 --- a/src/private/repository/Test-RepositoryReceiptOwnerAlive.ps1 +++ b/src/private/repository/Test-RepositoryReceiptOwnerAlive.ps1 @@ -1,6 +1,11 @@ function Test-RepositoryReceiptOwnerAlive { param([object]$Receipt) - if ([long]$Receipt.OwnerPid -gt [int]::MaxValue) { Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt process identity is invalid.' } + if ([long]$Receipt.OwnerPid -gt [int]::MaxValue) { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Repository receipt process identity is invalid.' + } + if ($IsLinux -and "$($Receipt.OwnerStartedUtcTicks)" -notmatch '^1[0-9]{17}$') { + Write-PMUError 'PMU.RepositoryRecoveryRequired' 'Legacy Linux repository receipt identity requires explicit recovery; automatic cleanup is disabled.' + } $started = Get-RepositoryProcessStart ([int]$Receipt.OwnerPid) $started -ceq $Receipt.OwnerStartedUtcTicks } diff --git a/src/private/repository/Undo-OwnedRepositoryRegistration.ps1 b/src/private/repository/Undo-OwnedRepositoryRegistration.ps1 new file mode 100644 index 0000000..d32135d --- /dev/null +++ b/src/private/repository/Undo-OwnedRepositoryRegistration.ps1 @@ -0,0 +1,18 @@ +function Undo-OwnedRepositoryRegistration { + param( + [object]$Lease, + [System.Management.Automation.ErrorRecord]$Failure + ) + + if (-not (Test-Path -LiteralPath $Lease.ReceiptPath -PathType Leaf)) { + throw $Failure + } + try { + Clear-OwnedRepositoryReceipt ([pscustomobject]@{ + Path = $Lease.ReceiptPath + Receipt = $Lease.Receipt + }) + } catch { + Write-RepositoryCleanupFailure $Failure + } +} diff --git a/src/private/repository/Write-BoundRepositoryReceipt.ps1 b/src/private/repository/Write-BoundRepositoryReceipt.ps1 index b22fd37..850e6e5 100644 --- a/src/private/repository/Write-BoundRepositoryReceipt.ps1 +++ b/src/private/repository/Write-BoundRepositoryReceipt.ps1 @@ -2,8 +2,11 @@ function Write-BoundRepositoryReceipt { param([object]$Lease) $path = Assert-SafePath $Lease.ReceiptPath $temporary = Write-RepositoryReceipt $Lease.Receipt $Lease.Root '.binding' - try { [IO.File]::Move($temporary, $path, $true) } - finally { - if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -ErrorAction Stop } + try { + [IO.File]::Move($temporary, $path, $true) + } finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -ErrorAction Stop + } } } diff --git a/src/private/repository/Write-RepositoryReceipt.ps1 b/src/private/repository/Write-RepositoryReceipt.ps1 index fa12c0d..035b85b 100644 --- a/src/private/repository/Write-RepositoryReceipt.ps1 +++ b/src/private/repository/Write-RepositoryReceipt.ps1 @@ -1,12 +1,19 @@ function Write-RepositoryReceipt { - param([object]$Receipt, [string]$Root, [string]$Suffix = '') + param( + [object]$Receipt, + [string]$Root, + [string]$Suffix = '' + ) + $path = Assert-SafePath (Join-Path $Root "$($Receipt.OperationId).json$Suffix") $bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Receipt -Depth 4 -Compress)) - $options = [IO.FileStreamOptions]::new() - $options.Mode = [IO.FileMode]::CreateNew; $options.Access = [IO.FileAccess]::Write; $options.Share = [IO.FileShare]::None - if (-not $IsWindows) { $options.UnixCreateMode = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite } + $options = Get-RepositoryReceiptStreamOption $stream = [IO.FileStream]::new($path, $options) - try { $stream.Write($bytes); $stream.Flush($true) } - finally { $stream.Dispose() } + try { + $stream.Write($bytes) + $stream.Flush($true) + } finally { + $stream.Dispose() + } $path } diff --git a/src/private/shared/Assert-MatchingProperty.ps1 b/src/private/shared/Assert-MatchingProperty.ps1 index 7cfa058..1210c6c 100644 --- a/src/private/shared/Assert-MatchingProperty.ps1 +++ b/src/private/shared/Assert-MatchingProperty.ps1 @@ -1,5 +1,11 @@ function Assert-MatchingProperty { - param([object]$Actual, [object]$Expected, [string[]]$Property, [string]$ErrorId) + param( + [object]$Actual, + [object]$Expected, + [string[]]$Property, + [string]$ErrorId + ) + foreach ($name in $Property) { if ($Actual.$name -cne $Expected.$name) { Write-PMUError $ErrorId 'A required identity or environment precondition changed.' diff --git a/src/private/shared/Write-PMUError.ps1 b/src/private/shared/Write-PMUError.ps1 index 7783b28..926844e 100644 --- a/src/private/shared/Write-PMUError.ps1 +++ b/src/private/shared/Write-PMUError.ps1 @@ -1,5 +1,10 @@ function Write-PMUError { - param([string]$Id, [string]$Message, [string]$Guidance = 'Obtain a fresh plan after correcting the reported condition.') + param( + [string]$Id, + [string]$Message, + [string]$Guidance = 'Obtain a fresh plan after correcting the reported condition.' + ) + $exception = [InvalidOperationException]::new($Message) $target = [pscustomobject]@{ RecoveryGuidance = $Guidance } throw [System.Management.Automation.ErrorRecord]::new($exception, $Id, 'InvalidOperation', $target) diff --git a/src/private/staging/Clear-StagingRoot.ps1 b/src/private/staging/Clear-StagingRoot.ps1 index e855860..7e7cd4e 100644 --- a/src/private/staging/Clear-StagingRoot.ps1 +++ b/src/private/staging/Clear-StagingRoot.ps1 @@ -1,9 +1,13 @@ function Clear-StagingRoot { param([object]$Operation) $expected = Join-Path $Operation.WorkRoot 'stage' - if ($Operation.StageRoot -cne $expected) { Write-PMUError 'PMU.RecoveryRequired' 'Refusing to clear an unexpected staging path.' } + if ($Operation.StageRoot -cne $expected) { + Write-PMUError 'PMU.RecoveryRequired' 'Refusing to clear an unexpected staging path.' + } $null = Assert-SafePath $expected - if (-not (Test-Path -LiteralPath $expected)) { return } + if (-not (Test-Path -LiteralPath $expected)) { + return + } $null = @(Get-FileTreeInventory $expected) Remove-Item -LiteralPath $expected -Recurse -Force -ErrorAction Stop } diff --git a/src/private/staging/Initialize-StagedPackage.ps1 b/src/private/staging/Initialize-StagedPackage.ps1 index 63c86d3..0c6f276 100644 --- a/src/private/staging/Initialize-StagedPackage.ps1 +++ b/src/private/staging/Initialize-StagedPackage.ps1 @@ -3,12 +3,14 @@ function Initialize-StagedPackage { Write-InstallationPhase $Operation.Plan 'Staging' Clear-StagingRoot $Operation Initialize-ControlledDirectory $Operation.StageRoot - Assert-RepositoryBinding $Operation.Plan $Operation.RepositoryName + Assert-NativeRepositoryBinding $Operation Save-ProviderResource $Operation $manifest = Join-Path $Operation.StagedPath "$($Operation.Plan.ModuleName).psd1" Assert-PackageIdentity (Get-ManifestIdentity $manifest) $Operation.Plan $tree = @(Get-FileTreeInventory $Operation.StagedPath) - if ($tree.Count -lt 2) { Write-PMUError 'PMU.VerificationFailed' 'The extracted module tree is incomplete.' } + if ($tree.Count -lt 2) { + Write-PMUError 'PMU.VerificationFailed' 'The extracted module tree is incomplete.' + } Assert-PackageSource $Operation.StagedPath $Operation.Plan $tree } diff --git a/src/private/verification/Assert-FileTreeEqual.ps1 b/src/private/verification/Assert-FileTreeEqual.ps1 index 44ee2d3..92bab22 100644 --- a/src/private/verification/Assert-FileTreeEqual.ps1 +++ b/src/private/verification/Assert-FileTreeEqual.ps1 @@ -1,6 +1,13 @@ function Assert-FileTreeEqual { - param([object[]]$Expected, [object[]]$Actual, [string]$ErrorId = 'PMU.VerificationFailed') + param( + [object[]]$Expected, + [object[]]$Actual, + [string]$ErrorId = 'PMU.VerificationFailed' + ) + $left = ConvertTo-Json @($Expected) -Depth 4 -Compress $right = ConvertTo-Json @($Actual) -Depth 4 -Compress - if ($left -cne $right) { Write-PMUError $ErrorId 'The complete file tree does not match the verified reference.' } + if ($left -cne $right) { + Write-PMUError $ErrorId 'The complete file tree does not match the verified reference.' + } } diff --git a/src/private/verification/Assert-InstalledPackage.ps1 b/src/private/verification/Assert-InstalledPackage.ps1 index 3939791..d2b39e3 100644 --- a/src/private/verification/Assert-InstalledPackage.ps1 +++ b/src/private/verification/Assert-InstalledPackage.ps1 @@ -1,13 +1,21 @@ function Assert-InstalledPackage { - param([object]$Operation, [object[]]$ReferenceTree) + param( + [object]$Operation, + [object[]]$ReferenceTree + ) + $plan = $Operation.Plan $path = Assert-SafePath $plan.TargetInstalledPath $expected = Join-Path (Resolve-ModuleInstallationRoot $plan.Scope) "$($plan.ModuleName)/$($plan.TargetNumericVersion)" - if ($path -cne $expected) { Write-PMUError 'PMU.VerificationFailed' 'The provider destination does not match the planned scope root.' } + if ($path -cne $expected) { + Write-PMUError 'PMU.VerificationFailed' 'The provider destination does not match the planned scope root.' + } $manifest = Join-Path $path "$($plan.ModuleName).psd1" Assert-PackageIdentity (Get-ManifestIdentity $manifest) $plan Assert-FileTreeEqual $ReferenceTree @(Get-FileTreeInventory $path) Assert-PackageSource $path $plan $discovered = @(Get-Module -ListAvailable $manifest | Where-Object Guid -EQ $plan.ModuleGuid) - if ($discovered.Count -ne 1) { Write-PMUError 'PMU.VerificationFailed' 'The installed module is not discoverable at its exact manifest path.' } + if ($discovered.Count -ne 1) { + Write-PMUError 'PMU.VerificationFailed' 'The installed module is not discoverable at its exact manifest path.' + } } diff --git a/src/private/verification/Assert-PackageIdentity.ps1 b/src/private/verification/Assert-PackageIdentity.ps1 index e06c91e..1794c01 100644 --- a/src/private/verification/Assert-PackageIdentity.ps1 +++ b/src/private/verification/Assert-PackageIdentity.ps1 @@ -1,7 +1,13 @@ function Assert-PackageIdentity { - param([object]$Identity, [object]$Plan) + param( + [object]$Identity, + [object]$Plan + ) + if ($Identity.Name -cne $Plan.ModuleName -or $Identity.Version.FullVersion -cne $Plan.TargetVersion) { Write-PMUError 'PMU.VerificationFailed' 'The package manifest does not match the exact planned name and complete version.' } - if ($Identity.Guid -ne $Plan.ModuleGuid) { Write-PMUError 'PMU.GuidMismatch' 'The package manifest GUID differs from the planned identity.' } + if ($Identity.Guid -ne $Plan.ModuleGuid) { + Write-PMUError 'PMU.GuidMismatch' 'The package manifest GUID differs from the planned identity.' + } } diff --git a/src/private/verification/Assert-PackageSource.ps1 b/src/private/verification/Assert-PackageSource.ps1 index 5463243..ab08ee1 100644 --- a/src/private/verification/Assert-PackageSource.ps1 +++ b/src/private/verification/Assert-PackageSource.ps1 @@ -1,9 +1,17 @@ function Assert-PackageSource { - param([string]$Path, [object]$Plan) + param( + [string]$Path, + [object]$Plan + ) + $property = Get-PackageSourceProperty $Path - if (-not $property) { return } - if (-not $property.Value) { return } - if ((Assert-RepositoryUri ([uri]$property.Value)) -cne $Plan.RepositoryUri) { + if (-not $property) { + return + } + if (-not $property.Value) { + return + } + if ((ConvertTo-ProviderSourceUri $property.Value) -cne $Plan.RepositoryUri) { Write-PMUError 'PMU.VerificationFailed' 'Package source metadata does not match the planned repository.' } } diff --git a/src/private/verification/ConvertTo-FileTreeEntry.ps1 b/src/private/verification/ConvertTo-FileTreeEntry.ps1 new file mode 100644 index 0000000..d48432b --- /dev/null +++ b/src/private/verification/ConvertTo-FileTreeEntry.ps1 @@ -0,0 +1,18 @@ +function ConvertTo-FileTreeEntry { + param( + [IO.FileSystemInfo]$Item, + [string]$RelativePath + ) + + $entry = [pscustomobject]@{ + Path = "$RelativePath/" + Length = 0 + SHA256 = '' + } + if (-not $Item.PSIsContainer) { + $entry.Path = $RelativePath + $entry.Length = $Item.Length + $entry.SHA256 = (Get-FileHash -LiteralPath $Item.FullName -Algorithm SHA256).Hash + } + $entry +} diff --git a/src/private/verification/Get-FileTreeInventory.ps1 b/src/private/verification/Get-FileTreeInventory.ps1 index 3ab746c..bdb434a 100644 --- a/src/private/verification/Get-FileTreeInventory.ps1 +++ b/src/private/verification/Get-FileTreeInventory.ps1 @@ -4,12 +4,10 @@ function Get-FileTreeInventory { foreach ($item in Get-ChildItem -LiteralPath $root -Recurse -Force -ErrorAction Stop | Sort-Object FullName -CaseSensitive) { $null = Assert-SafePath $item.FullName $relative = [IO.Path]::GetRelativePath($root, $item.FullName).Replace('\', '/') - if ($item.PSIsContainer) { - [pscustomobject]@{ Path = "$relative/"; Length = 0; SHA256 = '' } + # PSResourceGet intentionally generates this root-level installation metadata file. + if (-not $item.PSIsContainer -and $relative -ceq 'PSGetModuleInfo.xml') { continue } - # PSResourceGet intentionally generates this root-level installation metadata file. - if ($relative -ceq 'PSGetModuleInfo.xml') { continue } - [pscustomobject]@{ Path = $relative; Length = $item.Length; SHA256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash } + ConvertTo-FileTreeEntry -Item $item -RelativePath $relative } } diff --git a/src/private/verification/Get-PackageSourceProperty.ps1 b/src/private/verification/Get-PackageSourceProperty.ps1 index 076c027..d264943 100644 --- a/src/private/verification/Get-PackageSourceProperty.ps1 +++ b/src/private/verification/Get-PackageSourceProperty.ps1 @@ -1,8 +1,13 @@ function Get-PackageSourceProperty { param([string]$Path) $metadataPath = Join-Path $Path 'PSGetModuleInfo.xml' - if (-not (Test-Path -LiteralPath $metadataPath)) { return } - try { $metadata = Import-Clixml -LiteralPath $metadataPath -ErrorAction Stop } - catch { Write-PMUError 'PMU.VerificationFailed' 'Provider source metadata is unreadable.' } + if (-not (Test-Path -LiteralPath $metadataPath)) { + return + } + try { + $metadata = Import-Clixml -LiteralPath $metadataPath -ErrorAction Stop + } catch { + Write-PMUError 'PMU.VerificationFailed' 'Provider source metadata is unreadable.' + } $metadata.PSObject.Properties['RepositorySourceLocation'] } diff --git a/src/private/version/Compare-CompleteModuleVersion.ps1 b/src/private/version/Compare-CompleteModuleVersion.ps1 index 939d56d..9140660 100644 --- a/src/private/version/Compare-CompleteModuleVersion.ps1 +++ b/src/private/version/Compare-CompleteModuleVersion.ps1 @@ -1,8 +1,14 @@ function Compare-CompleteModuleVersion { - param([string]$Left, [string]$Right) + param( + [string]$Left, + [string]$Right + ) + $a = ConvertTo-CompleteModuleVersion $Left $b = ConvertTo-CompleteModuleVersion $Right $comparison = ([version]$a.NumericVersion).CompareTo([version]$b.NumericVersion) - if ($comparison) { return [Math]::Sign($comparison) } + if ($comparison) { + return [Math]::Sign($comparison) + } Compare-PrereleaseLabel $a.Prerelease $b.Prerelease } diff --git a/src/private/version/Compare-PrereleaseIdentifier.ps1 b/src/private/version/Compare-PrereleaseIdentifier.ps1 index ae261ea..0335f19 100644 --- a/src/private/version/Compare-PrereleaseIdentifier.ps1 +++ b/src/private/version/Compare-PrereleaseIdentifier.ps1 @@ -1,12 +1,18 @@ function Compare-PrereleaseIdentifier { - param([string]$Left, [string]$Right) + param( + [string]$Left, + [string]$Right + ) + $leftNumeric = $Left -match '^\d+$' $rightNumeric = $Right -match '^\d+$' if ($leftNumeric -and $rightNumeric) { return [Math]::Sign(([System.Numerics.BigInteger]::Parse($Left)).CompareTo([System.Numerics.BigInteger]::Parse($Right))) } if ($leftNumeric -ne $rightNumeric) { - if ($leftNumeric) { return -1 } + if ($leftNumeric) { + return -1 + } return 1 } [Math]::Sign([string]::CompareOrdinal($Left, $Right)) diff --git a/src/private/version/Compare-PrereleaseLabel.ps1 b/src/private/version/Compare-PrereleaseLabel.ps1 index cabb55c..6654788 100644 --- a/src/private/version/Compare-PrereleaseLabel.ps1 +++ b/src/private/version/Compare-PrereleaseLabel.ps1 @@ -1,7 +1,17 @@ function Compare-PrereleaseLabel { - param([string]$Left, [string]$Right) - if ($Left -ceq $Right) { return 0 } - if (-not $Left) { return 1 } - if (-not $Right) { return -1 } + param( + [string]$Left, + [string]$Right + ) + + if ($Left -ceq $Right) { + return 0 + } + if (-not $Left) { + return 1 + } + if (-not $Right) { + return -1 + } Compare-PrereleaseSegmentList $Left.Split('.') $Right.Split('.') } diff --git a/src/private/version/Compare-PrereleaseSegmentList.ps1 b/src/private/version/Compare-PrereleaseSegmentList.ps1 index cd86698..6f05ff8 100644 --- a/src/private/version/Compare-PrereleaseSegmentList.ps1 +++ b/src/private/version/Compare-PrereleaseSegmentList.ps1 @@ -1,8 +1,14 @@ function Compare-PrereleaseSegmentList { - param([string[]]$Left, [string[]]$Right) + param( + [string[]]$Left, + [string[]]$Right + ) + for ($index = 0; $index -lt [Math]::Min($Left.Count, $Right.Count); $index++) { $comparison = Compare-PrereleaseIdentifier $Left[$index] $Right[$index] - if ($comparison) { return $comparison } + if ($comparison) { + return $comparison + } } [Math]::Sign($Left.Count - $Right.Count) } diff --git a/src/private/version/ConvertTo-CompleteModuleVersion.ps1 b/src/private/version/ConvertTo-CompleteModuleVersion.ps1 index 999fc68..1bbaa58 100644 --- a/src/private/version/ConvertTo-CompleteModuleVersion.ps1 +++ b/src/private/version/ConvertTo-CompleteModuleVersion.ps1 @@ -8,5 +8,10 @@ function ConvertTo-CompleteModuleVersion { if (@($label -split '\.' | Where-Object { $_ -match '^0\d+$' }).Count) { Write-PMUError 'PMU.UnsupportedProviderBehavior' 'Numeric prerelease identifiers cannot have leading zeros.' } - [pscustomobject]@{ FullVersion = $Version; NumericVersion = $numeric.ToString(); Prerelease = "$label"; IsPrerelease = [bool]$label } + [pscustomobject]@{ + FullVersion = $Version + NumericVersion = $numeric.ToString() + Prerelease = "$label" + IsPrerelease = [bool]$label + } } diff --git a/src/public/Get-PSModuleUpdate.ps1 b/src/public/Get-PSModuleUpdate.ps1 index 0f23664..2df5ab2 100644 --- a/src/public/Get-PSModuleUpdate.ps1 +++ b/src/public/Get-PSModuleUpdate.ps1 @@ -36,7 +36,10 @@ function Get-PSModuleUpdate { [hashtable]$Policy = @{}, [pscredential]$Credential ) - $selection = @{ Name = $Repository; Uri = $RepositoryUri } + $selection = @{ + Name = $Repository + Uri = $RepositoryUri + } $context = Get-PlanningContext $Module $selection $Policy $Credential Register-UpdatePlan (ConvertTo-UpdatePlan $context) $context ([bool]$Credential) } diff --git a/src/public/Install-PSModuleUpdate.ps1 b/src/public/Install-PSModuleUpdate.ps1 index 848e29a..a0eb281 100644 --- a/src/public/Install-PSModuleUpdate.ps1 +++ b/src/public/Install-PSModuleUpdate.ps1 @@ -33,9 +33,15 @@ function Install-PSModuleUpdate { [Parameter(ParameterSetName = 'ByUpdatePlan')][switch]$TrustRepository ) process { - $operation = Initialize-InstallationRequest $Update $Credential @{ AcceptLicense = [bool]$AcceptLicense; TrustRepository = [bool]$TrustRepository } + $operation = Initialize-InstallationRequest $Update $Credential @{ + AcceptLicense = [bool]$AcceptLicense + TrustRepository = [bool]$TrustRepository + } $target = "$($Update.ModuleName) $($Update.TargetVersion) from $($Update.RepositoryName ?? $Update.RepositoryUri) into $($Update.Scope)" - if ($PSCmdlet.ShouldProcess($target, 'Install exact module update')) { Invoke-UpdateInstallation $operation } - else { ConvertTo-DeclinedUpdateResult $Update } + if ($PSCmdlet.ShouldProcess($target, 'Install exact module update')) { + Invoke-UpdateInstallation $operation + } else { + ConvertTo-DeclinedUpdateResult $Update + } } } diff --git a/tests/ModuleArchitecture.Tests.ps1 b/tests/ModuleArchitecture.Tests.ps1 index 1a5f00f..5624027 100644 --- a/tests/ModuleArchitecture.Tests.ps1 +++ b/tests/ModuleArchitecture.Tests.ps1 @@ -22,14 +22,27 @@ Describe 'PMU architecture' -Tag 'PMU' { It 'does not bootstrap or hot reload and confines registration writes to owned transport adapters' { foreach ($file in $files) { $text = Get-Content $file.FullName -Raw - $text | Should -Not -Match '(?i)\b(Set-PSResourceRepository|Remove-Module|Update-Module|Install-Module)\b' + $text | Should -Not -Match '(?i)\b(Set-PSResourceRepository|Remove-Module|Install-Module)\b' + if ($text -match '\bUpdate-Module\b') { + $file.BaseName | Should -Be 'Install-ProviderResource' + } + if ($file.Directory.Name -eq 'provider') { + $text | Should -Not -Match '(?i)\bForce\b' + } if ($text -match '\bRegister-PSResourceRepository\b') { $file.BaseName | Should -Be 'Register-OwnedRepository' $text | Should -Not -Match '(?i)\b(Force|CredentialInfo|Trusted)\b' } - if ($text -match '\bUnregister-PSResourceRepository\b') { $file.BaseName | Should -Be 'Clear-OwnedRepositoryReceipt' } + if ($text -match '\bUnregister-PSResourceRepository\b') { + $file.BaseName | Should -Be 'Clear-OwnedRepositoryReceipt' + } } } + It 'applies updates only through the native Update-Module adapter' -Tag 'PMUNativeAdapter' { + $adapter = Get-Content "$root/src/private/provider/Install-ProviderResource.ps1" -Raw + $adapter | Should -Match 'PowerShellGet\\Update-Module' + $adapter | Should -Not -Match '\bInstall-PSResource\b' + } It 'has a source-mirrored test for every production helper and public command' { foreach ($file in $files) { $relative = [IO.Path]::GetRelativePath("$root/src", $file.FullName) diff --git a/tests/NativeWorkerLifetime.Tests.ps1 b/tests/NativeWorkerLifetime.Tests.ps1 new file mode 100644 index 0000000..b30b602 --- /dev/null +++ b/tests/NativeWorkerLifetime.Tests.ps1 @@ -0,0 +1,102 @@ +BeforeAll { + . "$PSScriptRoot/TestHelpers/Import-PMUTestSource.ps1" + . "$PSScriptRoot/TestHelpers/NativeWorkerLifetime.TestSupport.ps1" + foreach ($source in Get-PMUTestSourcePath "$PSScriptRoot/TestHelpers/NativeWorkerLifetime.TestSupport.ps1") { + . $source + } +} + +Describe 'Native worker cross-process installation ownership' -Tag 'PMU', 'PMUWorkerLifetime' { + BeforeEach { + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $script:operation = Initialize-PMURecoveryFixture $root + $operation | Add-Member NativeContext ([pscustomobject]@{ Root = Join-Path $root 'native' }) + $script:journalHash = (Get-FileHash -LiteralPath $operation.JournalPath).Hash + $script:lease = Enter-InstallationLock $operation.LockPath + $script:probe = $null + $script:probe = Invoke-PMUNativeLeaseProbe $operation $root + (Receive-PMUNativeLeaseMessage $probe).Phase | Should -BeExactly 'READY' + } + + AfterEach { + if ($lease) { + $lease.Dispose() + } + if ($probe) { + Close-PMUChildProcess @($probe.Worker, $probe.Parent) + } + } + + It 'does not mutate when the approving process dies before an explicit GO' { + $probe.Parent.Kill() + $probe.Parent.WaitForExit(10000) | Should -BeTrue + $probe.Worker.StandardInput.Close() + + $result = Receive-PMUNativeLeaseMessage $probe + + $result.Success | Should -BeFalse + $result.ErrorId | Should -BeExactly 'PMU.PlanStale' + (Get-FileHash -LiteralPath $operation.JournalPath).Hash | Should -BeExactly $journalHash + Test-Path (Join-Path $operation.NativeContext.Root 'writer-finished') | Should -BeFalse + } + + It 'rechecks approving-process identity after waiting for the original installation lock' { + $probe.Worker.StandardInput.WriteLine('GO') + $probe.Parent.Kill() + $probe.Parent.WaitForExit(10000) | Should -BeTrue + $lease.Dispose() + $script:lease = $null + + $result = Receive-PMUNativeLeaseMessage $probe + + $result.Success | Should -BeFalse + $result.ErrorId | Should -BeExactly 'PMU.PlanStale' + (Get-FileHash -LiteralPath $operation.JournalPath).Hash | Should -BeExactly $journalHash + Test-Path -LiteralPath $operation.BackupPath | Should -BeTrue + } + + It 'refuses a stale handoff after wins the lock gap' -ForEach @( + @{ Winner = 'Recovery' } + @{ Winner = 'Replacement' } + ) { + $probe.Worker.StandardInput.WriteLine('GO') + if ($Winner -eq 'Recovery') { + Write-RecoveryJournalState $operation.Journal 'RecoveryCompleted' + } else { + $operation.Journal.TransactionId = [guid]::NewGuid().ToString('N') + Write-DurableJournal $operation.Journal + } + $expectedHash = (Get-FileHash -LiteralPath $operation.JournalPath).Hash + $lease.Dispose() + $script:lease = $null + + $result = Receive-PMUNativeLeaseMessage $probe + + $result.Success | Should -BeFalse + $result.ErrorId | Should -BeExactly 'PMU.PlanStale' + (Get-FileHash -LiteralPath $operation.JournalPath).Hash | Should -BeExactly $expectedHash + Test-Path -LiteralPath $operation.BackupPath | Should -BeTrue + } + + It 'keeps the original lock while a writer outlives its approving process and input pipe' { + $lease.Dispose() + $script:lease = $null + $probe.Worker.StandardInput.WriteLine('GO') + (Receive-PMUNativeLeaseMessage $probe).Phase | Should -BeExactly 'WRITING' + $probe.Parent.Kill() + $probe.Parent.WaitForExit(10000) | Should -BeTrue + $probe.Worker.StandardInput.Close() + + { Enter-InstallationLock $operation.LockPath 100 } | Should -Throw -ErrorId 'PMU.LockTimeout' + Test-Path (Join-Path $operation.NativeContext.Root 'writer-finished') | Should -BeFalse + + Set-Content -LiteralPath (Join-Path $operation.NativeContext.Root 'release-writer') -Value 'release' + (Receive-PMUNativeLeaseMessage $probe).Success | Should -BeTrue + $probe.Worker.WaitForExit(10000) | Should -BeTrue + $probe.Worker.ExitCode | Should -Be 0 + Get-Content (Join-Path $operation.NativeContext.Root 'writer-finished') | Should -BeExactly 'complete' + $recoveryLease = Enter-InstallationLock $operation.LockPath 100 + $recoveryLease.Dispose() + (Get-FileHash -LiteralPath $operation.JournalPath).Hash | Should -BeExactly $journalHash + } +} diff --git a/tests/RuntimeDependency.Tests.ps1 b/tests/RuntimeDependency.Tests.ps1 new file mode 100644 index 0000000..366ade1 --- /dev/null +++ b/tests/RuntimeDependency.Tests.ps1 @@ -0,0 +1,16 @@ +BeforeAll { + $script:project = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../project.json') -Raw | + ConvertFrom-Json +} + +Describe 'PMU runtime dependency contract' -Tag 'RuntimeDependency' { + It 'requires the exact qualified PSResourceGet version' { + $requirement = $script:project.Manifest.RequiredModules | + Where-Object ModuleName -CEQ 'Microsoft.PowerShell.PSResourceGet' + + @($requirement).Count | Should -Be 1 + $requirement.RequiredVersion | Should -Be '1.2.0' + $requirement.ModuleVersion | Should -BeNullOrEmpty + $requirement.MaximumVersion | Should -BeNullOrEmpty + } +} diff --git a/tests/TestHelpers/Assert-PMULicenseConsent.ps1 b/tests/TestHelpers/Assert-PMULicenseConsent.ps1 index cc224bb..3785802 100644 --- a/tests/TestHelpers/Assert-PMULicenseConsent.ps1 +++ b/tests/TestHelpers/Assert-PMULicenseConsent.ps1 @@ -1,8 +1,11 @@ function Assert-PMULicenseConsent { param([object]$Plan) - try { $null = $Plan | Install-PSModuleUpdate -TrustRepository -Confirm:$false } - catch { - if ($_.FullyQualifiedErrorId -notlike 'PMU.LicenseAcceptanceRequired*') { throw } + try { + $null = $Plan | Install-PSModuleUpdate -TrustRepository -Confirm:$false + } catch { + if ($_.FullyQualifiedErrorId -notlike 'PMU.LicenseAcceptanceRequired*') { + throw + } return } throw 'A licensed fixture was installed without explicit acceptance.' diff --git a/tests/TestHelpers/Assert-PMUNativeFixtureStore.ps1 b/tests/TestHelpers/Assert-PMUNativeFixtureStore.ps1 new file mode 100644 index 0000000..02213e6 --- /dev/null +++ b/tests/TestHelpers/Assert-PMUNativeFixtureStore.ps1 @@ -0,0 +1,20 @@ +function Assert-PMUNativeFixtureStore { + param( + [string]$Root, + [string]$ExpectedDestination + ) + + Import-Module (Join-Path $PSHOME 'Modules/PackageManagement/PackageManagement.psd1') -RequiredVersion 1.4.8.1 + Import-Module (Join-Path $PSHOME 'Modules/PowerShellGet/PowerShellGet.psd1') -RequiredVersion 2.2.5 + $paths = & (Get-Module PSModuleUpdate) { Assert-NativeNuGetProvider + Get-NativeProviderPath } + $prefix = [IO.Path]::GetFullPath((Join-Path $Root 'home')) + [IO.Path]::DirectorySeparatorChar + foreach ($path in @($paths.Store, $paths.Settings, $paths.Config, $paths.Temporary)) { + if (-not [IO.Path]::GetFullPath($path).StartsWith($prefix, [StringComparison]::Ordinal)) { + throw 'The fixture refuses a native writer outside its owned home.' + } + } + if ($paths.CurrentUser -cne $ExpectedDestination) { + throw 'Native bootstrap changed the disposable destination.' + } +} diff --git a/tests/TestHelpers/Clear-PMUOwnedRegistration.ps1 b/tests/TestHelpers/Clear-PMUOwnedRegistration.ps1 index 9a1c986..16abcaf 100644 --- a/tests/TestHelpers/Clear-PMUOwnedRegistration.ps1 +++ b/tests/TestHelpers/Clear-PMUOwnedRegistration.ps1 @@ -1,9 +1,17 @@ function Clear-PMUOwnedRegistration { - param([object]$Fixture, [object]$Before) + param( + [object]$Fixture, + [object]$Before + ) + if (Get-PSResourceRepository -Name $Fixture.Repository -ErrorAction SilentlyContinue) { Unregister-PSResourceRepository -Name $Fixture.Repository -Confirm:$false } $after = Get-PMURegistrationSnapshot - if ($Before.Records -cne $after.Records) { throw 'Existing provider registrations were not preserved.' } - if ($Before.Hash -cne $after.Hash) { throw 'Provider registration store bytes changed after cleanup.' } + if ($Before.Records -cne $after.Records) { + throw 'Existing provider registrations were not preserved.' + } + if ($Before.Hash -cne $after.Hash) { + throw 'Provider registration store bytes changed after cleanup.' + } } diff --git a/tests/TestHelpers/Close-PMUChildProcess.ps1 b/tests/TestHelpers/Close-PMUChildProcess.ps1 index 98f931b..7527b2f 100644 --- a/tests/TestHelpers/Close-PMUChildProcess.ps1 +++ b/tests/TestHelpers/Close-PMUChildProcess.ps1 @@ -1,7 +1,9 @@ function Close-PMUChildProcess { param([Diagnostics.Process[]]$Process) foreach ($child in $Process) { - if (-not $child.HasExited) { $child.Kill($true) } + if (-not $child.HasExited) { + $child.Kill($true) + } $child.Dispose() } } diff --git a/tests/TestHelpers/ConvertTo-PMUProcessFailure.ps1 b/tests/TestHelpers/ConvertTo-PMUProcessFailure.ps1 index 8d90cfd..cc8489c 100644 --- a/tests/TestHelpers/ConvertTo-PMUProcessFailure.ps1 +++ b/tests/TestHelpers/ConvertTo-PMUProcessFailure.ps1 @@ -1,9 +1,18 @@ function ConvertTo-PMUProcessFailure { - param([Management.Automation.ErrorRecord]$Failure, [string]$Mode) + param( + [Management.Automation.ErrorRecord]$Failure, + [string]$Mode + ) + if ($Mode -eq 'Attempt' -and $Failure.FullyQualifiedErrorId -like 'PMU.LockTimeout*') { [pscustomobject]@{ ErrorId = $Failure.FullyQualifiedErrorId } | ConvertTo-Json -Compress exit 2 } - if ($Mode -notin @('Recover', 'RecoverBackup') -or $Failure.FullyQualifiedErrorId -notlike 'PMU.PlanStale*') { throw $Failure } - [pscustomobject]@{ Outcome = 'Recovered'; ErrorId = $Failure.FullyQualifiedErrorId } | ConvertTo-Json -Compress + if ($Mode -notin @('Recover', 'RecoverBackup') -or $Failure.FullyQualifiedErrorId -notlike 'PMU.PlanStale*') { + throw $Failure + } + [pscustomobject]@{ + Outcome = 'Recovered' + ErrorId = $Failure.FullyQualifiedErrorId + } | ConvertTo-Json -Compress } diff --git a/tests/TestHelpers/ConvertTo-PMUTestContext.ps1 b/tests/TestHelpers/ConvertTo-PMUTestContext.ps1 index 59872ee..d9726e6 100644 --- a/tests/TestHelpers/ConvertTo-PMUTestContext.ps1 +++ b/tests/TestHelpers/ConvertTo-PMUTestContext.ps1 @@ -1,11 +1,36 @@ function ConvertTo-PMUTestContext { - param([object]$Module, [object]$Identity, [object]$Observation) + param( + [object]$Module, + [object]$Identity, + [object]$Observation + ) + [pscustomobject]@{ - Module = $Module; Reference = $Identity; Active = Get-ActiveModuleIdentity $Module - Policy = @{ Scope = 'CurrentUser'; Channel = 'IncludePrerelease'; VersionRange = $null } - Provider = [pscustomobject]@{ Name = 'Microsoft.PowerShell.PSResourceGet'; Version = [version]'1.2.0' } - Source = [pscustomobject]@{ Name = 'OwnedFeed'; Uri = 'https://example.invalid/feed'; ApiVersion = 'V3'; Trusted = $true } - Observed = @($Observation); Installed = $Observation; InstalledTree = @(Get-FileTreeInventory (Split-Path -Parent $Identity.ManifestPath)) - Target = [pscustomobject]@{ Version = (ConvertTo-CompleteModuleVersion '2.0.0-preview.2'); RequiresLicenseAcceptance = $false; Dependencies = @() } + Module = $Module + Reference = $Identity + Active = Get-ActiveModuleIdentity $Module + Policy = @{ + Scope = 'CurrentUser' + Channel = 'IncludePrerelease' + VersionRange = $null + } + Provider = [pscustomobject]@{ + Name = 'Microsoft.PowerShell.PSResourceGet' + Version = [version]'1.2.0' + } + Source = [pscustomobject]@{ + Name = 'OwnedFeed' + Uri = 'https://example.invalid/feed' + ApiVersion = 'V3' + Trusted = $true + } + Observed = @($Observation) + Installed = $Observation + InstalledTree = @(Get-FileTreeInventory (Split-Path -Parent $Identity.ManifestPath)) + Target = [pscustomobject]@{ + Version = (ConvertTo-CompleteModuleVersion '2.0.0-preview.2') + RequiresLicenseAcceptance = $false + Dependencies = @() + } } } diff --git a/tests/TestHelpers/Get-PMUBackupModuleReference.ps1 b/tests/TestHelpers/Get-PMUBackupModuleReference.ps1 index 433430d..4b84f90 100644 --- a/tests/TestHelpers/Get-PMUBackupModuleReference.ps1 +++ b/tests/TestHelpers/Get-PMUBackupModuleReference.ps1 @@ -1,7 +1,9 @@ function Get-PMUBackupModuleReference { param([string]$Root) $journals = @(Get-ChildItem "$Root/data" -Filter 'journal.json' -Recurse) - if ($journals.Count -ne 1) { throw 'Expected exactly one isolated fixture journal.' } + if ($journals.Count -ne 1) { + throw 'Expected exactly one isolated fixture journal.' + } $journal = Get-Content $journals[0].FullName -Raw | ConvertFrom-Json Get-Module -ListAvailable (Join-Path $journal.BackupPath "$($journal.ModuleName).psd1") } diff --git a/tests/TestHelpers/Get-PMUIdentityProbeStartInfo.ps1 b/tests/TestHelpers/Get-PMUIdentityProbeStartInfo.ps1 new file mode 100644 index 0000000..46f9d0a --- /dev/null +++ b/tests/TestHelpers/Get-PMUIdentityProbeStartInfo.ps1 @@ -0,0 +1,13 @@ +function Get-PMUIdentityProbeStartInfo { + param([string]$Command) + $start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command)) + foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded)) { + $start.ArgumentList.Add($argument) + } + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start +} diff --git a/tests/TestHelpers/Get-PMULocalSelection.ps1 b/tests/TestHelpers/Get-PMULocalSelection.ps1 index b430757..8d8cdf9 100644 --- a/tests/TestHelpers/Get-PMULocalSelection.ps1 +++ b/tests/TestHelpers/Get-PMULocalSelection.ps1 @@ -1,5 +1,11 @@ function Get-PMULocalSelection { - param([object]$Fixture, [string]$Version) - if ($Version -eq '2.0.0-preview.2') { return @{ Repository = $Fixture.Repository } } + param( + [object]$Fixture, + [string]$Version + ) + + if ($Version -eq '2.0.0-preview2') { + return @{ Repository = $Fixture.Repository } + } @{ RepositoryUri = [uri]::new([IO.Path]::GetFullPath($Fixture.Feed), [UriKind]::Absolute) } } diff --git a/tests/TestHelpers/Get-PMUNativeRegistryRootProbe.ps1 b/tests/TestHelpers/Get-PMUNativeRegistryRootProbe.ps1 index c0ccbf1..b5f9a53 100644 --- a/tests/TestHelpers/Get-PMUNativeRegistryRootProbe.ps1 +++ b/tests/TestHelpers/Get-PMUNativeRegistryRootProbe.ps1 @@ -1,20 +1,34 @@ function Get-PMUNativeRegistryRootProbe { - param([string]$HomeDirectory, [string]$DataDirectory) + param( + [string]$HomeDirectory, + [string]$DataDirectory + ) + $null = New-Item (Join-Path $HomeDirectory '.local/share') -ItemType Directory -Force - if ($DataDirectory) { $null = New-Item $DataDirectory -ItemType Directory -Force } + if ($DataDirectory) { + $null = New-Item $DataDirectory -ItemType Directory -Force + } $start = Get-PMUNativeRegistryProbeStartInfo $HomeDirectory $DataDirectory $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true $result = Receive-PMUChildProcess ([Diagnostics.Process]::Start($start)) - if ($result.ExitCode -ne 0) { throw 'Read-only native registry-root probe failed.' } + if ($result.ExitCode -ne 0) { + throw 'Read-only native registry-root probe failed.' + } $result.Output | ConvertFrom-Json } # These sibling helpers are owned by this probe, not language-enforced private functions. function Get-PMUNativeRegistryProbeStartInfo { - param([string]$HomeDirectory, [string]$DataDirectory) + param( + [string]$HomeDirectory, + [string]$DataDirectory + ) + $start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) - foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command')) { $start.ArgumentList.Add($argument) } + foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command')) { + $start.ArgumentList.Add($argument) + } $start.Environment['PMU_PROBE_SOURCE'] = [IO.Path]::GetFullPath("$PSScriptRoot/../../src") $provider = Get-Module -ListAvailable Microsoft.PowerShell.PSResourceGet | Where-Object Version -EQ '1.2.0' | Select-Object -First 1 $start.Environment['PMU_PROBE_PROVIDER'] = $provider.Path @@ -22,7 +36,9 @@ function Get-PMUNativeRegistryProbeStartInfo { $start.ArgumentList.Add('. $env:PMU_PROBE_HELPER; Get-PMUNativeRegistryProbeState | ConvertTo-Json -Compress') $start.Environment['HOME'] = $HomeDirectory $null = $start.Environment.Remove('XDG_DATA_HOME') - if ($DataDirectory) { $start.Environment['XDG_DATA_HOME'] = $DataDirectory } + if ($DataDirectory) { + $start.Environment['XDG_DATA_HOME'] = $DataDirectory + } $start } diff --git a/tests/TestHelpers/Get-PMURegistrationSnapshot.Tests.ps1 b/tests/TestHelpers/Get-PMURegistrationSnapshot.Tests.ps1 new file mode 100644 index 0000000..5a093a1 --- /dev/null +++ b/tests/TestHelpers/Get-PMURegistrationSnapshot.Tests.ps1 @@ -0,0 +1,42 @@ +BeforeAll { + . "$PSScriptRoot/Get-PMURegistrationSnapshot.ps1" +} + +Describe 'Get-PMURegistrationSnapshot identity preservation' -Tag 'PMU', 'PMURegistrationSnapshot' { + BeforeEach { + $script:registrations = @('B', 'A') | ForEach-Object { + [pscustomobject]@{ + Name = $_ + Uri = [uri]"https://$_.example.invalid/" + Trusted = $false + Priority = 50 + ApiVersion = 'V2' + } + } + Mock Get-PSResourceRepository { $script:registrations } + Mock Get-FileHash { [pscustomobject]@{ Hash = 'unchanged-store' } } + } + + It 'compares identical registrations independently of provider enumeration order' { + $before = Get-PMURegistrationSnapshot + [array]::Reverse($script:registrations) + + $after = Get-PMURegistrationSnapshot + + $after.Records | Should -BeExactly $before.Records + } + + It 'still detects a changed ' -ForEach @( + @{ Field = 'Uri'; Value = [uri]'https://changed.example.invalid/' } + @{ Field = 'Trusted'; Value = $true } + @{ Field = 'Priority'; Value = 10 } + @{ Field = 'ApiVersion'; Value = 'V3' } + ) { + $before = Get-PMURegistrationSnapshot + $script:registrations[0].$Field = $Value + + $after = Get-PMURegistrationSnapshot + + $after.Records | Should -Not -BeExactly $before.Records + } +} diff --git a/tests/TestHelpers/Get-PMURegistrationSnapshot.ps1 b/tests/TestHelpers/Get-PMURegistrationSnapshot.ps1 index d4d6fc6..206bb12 100644 --- a/tests/TestHelpers/Get-PMURegistrationSnapshot.ps1 +++ b/tests/TestHelpers/Get-PMURegistrationSnapshot.ps1 @@ -1,5 +1,8 @@ function Get-PMURegistrationSnapshot { - $records = @(Get-PSResourceRepository | Select-Object Name, Uri, Trusted, Priority, ApiVersion) | ConvertTo-Json -Depth 5 -Compress + $records = @(Get-PSResourceRepository | Sort-Object Name | Select-Object Name, Uri, Trusted, Priority, ApiVersion) | ConvertTo-Json -Depth 5 -Compress $store = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSResourceGet/PSResourceRepository.xml' - [pscustomobject]@{ Records = $records; Hash = (Get-FileHash -LiteralPath $store -Algorithm SHA256).Hash } + [pscustomobject]@{ + Records = $records + Hash = (Get-FileHash -LiteralPath $store -Algorithm SHA256).Hash + } } diff --git a/tests/TestHelpers/Get-PMURepositoryEntry.ps1 b/tests/TestHelpers/Get-PMURepositoryEntry.ps1 index ab65d19..c621cbe 100644 --- a/tests/TestHelpers/Get-PMURepositoryEntry.ps1 +++ b/tests/TestHelpers/Get-PMURepositoryEntry.ps1 @@ -1,6 +1,16 @@ function Get-PMURepositoryEntry { [ordered]@{ - Public = [pscustomobject]@{ Name = 'PublicFixture'; Uri = [uri]'https://public.invalid/v3/index.json'; ApiVersion = 'V3'; Trusted = $true } - Private = [pscustomobject]@{ Name = 'PrivateFixture'; Uri = [uri]'https://private.invalid/v3/index.json'; ApiVersion = 'V3'; Trusted = $false } + Public = [pscustomobject]@{ + Name = 'PublicFixture' + Uri = [uri]'https://public.invalid/v3/index.json' + ApiVersion = 'V3' + Trusted = $true + } + Private = [pscustomobject]@{ + Name = 'PrivateFixture' + Uri = [uri]'https://private.invalid/v3/index.json' + ApiVersion = 'V3' + Trusted = $false + } } } diff --git a/tests/TestHelpers/Get-PMUTestCommandReference.ps1 b/tests/TestHelpers/Get-PMUTestCommandReference.ps1 index e9f62dd..78b4533 100644 --- a/tests/TestHelpers/Get-PMUTestCommandReference.ps1 +++ b/tests/TestHelpers/Get-PMUTestCommandReference.ps1 @@ -1,13 +1,22 @@ function Get-PMUTestCommandReference { - param([string]$Path, [hashtable]$Catalog) + param( + [string]$Path, + [hashtable]$Catalog + ) + $ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$null) $references = $ast.FindAll({ - param($node) - $node -is [Management.Automation.Language.CommandAst] -or $node -is [Management.Automation.Language.StringConstantExpressionAst] - }, $true) + param($node) + $node -is [Management.Automation.Language.CommandAst] -or $node -is [Management.Automation.Language.StringConstantExpressionAst] + }, $true) foreach ($reference in $references) { - if ($reference -is [Management.Automation.Language.CommandAst]) { $name = $reference.GetCommandName() } - else { $name = $reference.Value } - if ($Catalog.ContainsKey([string]$name)) { $Catalog[$name] } + if ($reference -is [Management.Automation.Language.CommandAst]) { + $name = $reference.GetCommandName() + } else { + $name = $reference.Value + } + if ($Catalog.ContainsKey([string]$name)) { + $Catalog[$name] + } } } diff --git a/tests/TestHelpers/Get-PMUTestSourcePath.ps1 b/tests/TestHelpers/Get-PMUTestSourcePath.ps1 index 1a28788..2bd7480 100644 --- a/tests/TestHelpers/Get-PMUTestSourcePath.ps1 +++ b/tests/TestHelpers/Get-PMUTestSourcePath.ps1 @@ -7,8 +7,12 @@ function Get-PMUTestSourcePath { $visited = [Collections.Generic.HashSet[string]]::new() while ($pending.Count) { $path = $pending.Dequeue() - if (-not $visited.Add($path)) { continue } - foreach ($dependency in Get-PMUTestCommandReference $path $catalog) { $pending.Enqueue($dependency) } + if (-not $visited.Add($path)) { + continue + } + foreach ($dependency in Get-PMUTestCommandReference $path $catalog) { + $pending.Enqueue($dependency) + } $path } } diff --git a/tests/TestHelpers/Import-PMUTestSource.ps1 b/tests/TestHelpers/Import-PMUTestSource.ps1 index 8c0c558..2bad548 100644 --- a/tests/TestHelpers/Import-PMUTestSource.ps1 +++ b/tests/TestHelpers/Import-PMUTestSource.ps1 @@ -1,4 +1,6 @@ Set-StrictMode -Version Latest . "$PSScriptRoot/Get-PMUTestCommandReference.ps1" . "$PSScriptRoot/Get-PMUTestSourcePath.ps1" -foreach ($sourceFile in Get-PMUTestSourcePath $MyInvocation.ScriptName) { . $sourceFile } +foreach ($sourceFile in Get-PMUTestSourcePath $MyInvocation.ScriptName) { + . $sourceFile +} diff --git a/tests/TestHelpers/Initialize-PMUInterruptedReplacement.ps1 b/tests/TestHelpers/Initialize-PMUInterruptedReplacement.ps1 index cdedfc6..b474262 100644 --- a/tests/TestHelpers/Initialize-PMUInterruptedReplacement.ps1 +++ b/tests/TestHelpers/Initialize-PMUInterruptedReplacement.ps1 @@ -1,6 +1,9 @@ function Initialize-PMUInterruptedReplacement { param([object]$Plan) - $operation = ConvertTo-InstallationOperation $Plan (Test-UpdatePlan $Plan) @{ AcceptLicense = $true; TrustRepository = $true } $null + $operation = ConvertTo-InstallationOperation $Plan (Test-UpdatePlan $Plan) @{ + AcceptLicense = $true + TrustRepository = $true + } $null Initialize-ControlledDirectory $operation.WorkRoot $tree = @(Initialize-StagedPackage $operation) Initialize-Replacement $operation $tree diff --git a/tests/TestHelpers/Initialize-PMULocalFixture.ps1 b/tests/TestHelpers/Initialize-PMULocalFixture.ps1 index 53e1e5e..563eb72 100644 --- a/tests/TestHelpers/Initialize-PMULocalFixture.ps1 +++ b/tests/TestHelpers/Initialize-PMULocalFixture.ps1 @@ -2,13 +2,18 @@ function Initialize-PMULocalFixture { param([string]$Root) $expectedRoot = [IO.Path]::GetFullPath((Join-Path $Root 'home/.local/share/powershell/Modules')) $actualRoot = [IO.Path]::GetFullPath((Get-PMUFixtureProviderRoot)) - if ($actualRoot -cne $expectedRoot) { throw 'Provider destination is not the isolated child HOME. No installation is permitted.' } + if ($actualRoot -cne $expectedRoot) { + throw 'Provider destination is not the isolated child HOME. No installation is permitted.' + } $fixture = [pscustomobject]@{ - Root = $Root; Feed = Join-Path $Root 'feed'; Name = "PmuOffline$([guid]::NewGuid().ToString('N'))" - Guid = [guid]::NewGuid(); Repository = "PmuOwned$([guid]::NewGuid().ToString('N'))"; Module = $null + Root = $Root + Feed = Join-Path $Root 'feed' + Name = "PmuOffline$([guid]::NewGuid().ToString('N'))" + Guid = [guid]::NewGuid() + Repository = "PmuOwned$([guid]::NewGuid().ToString('N'))" + Module = $null } $null = New-Item $fixture.Feed -ItemType Directory - $initial = Initialize-PMUTestPackage $actualRoot '2.0.0-preview.1' $fixture.Guid $fixture.Name - $fixture.Module = Get-Module -ListAvailable (Join-Path $initial "$($fixture.Name).psd1") + Initialize-PMUNativeLocalInstallation $fixture $actualRoot $fixture } diff --git a/tests/TestHelpers/Initialize-PMULocalPackage.ps1 b/tests/TestHelpers/Initialize-PMULocalPackage.ps1 index 5915db4..819fca3 100644 --- a/tests/TestHelpers/Initialize-PMULocalPackage.ps1 +++ b/tests/TestHelpers/Initialize-PMULocalPackage.ps1 @@ -1,8 +1,12 @@ function Initialize-PMULocalPackage { - param([object]$Fixture, [string]$Version) + param( + [object]$Fixture, + [string]$Version + ) + $root = Join-Path $Fixture.Root "package-$Version" $modulePath = Initialize-PMUTestPackage $root $Version $Fixture.Guid $Fixture.Name - $spec = '{0}{1}PMU fixtureDisposable offline fixturetruehttps://example.invalid/fixture-license' -f $Fixture.Name, $Version + $spec = '{0}{1}PMU fixtureDisposable offline fixturePSModule PowerShellGetFormatVersion_2.0truehttps://example.invalid/fixture-license' -f $Fixture.Name, $Version [IO.File]::WriteAllText((Join-Path $modulePath "$($Fixture.Name).nuspec"), $spec) $package = Join-Path $Fixture.Feed "$($Fixture.Name).$Version.nupkg" [IO.Compression.ZipFile]::CreateFromDirectory($modulePath, $package) diff --git a/tests/TestHelpers/Initialize-PMUNativeLocalInstallation.ps1 b/tests/TestHelpers/Initialize-PMUNativeLocalInstallation.ps1 new file mode 100644 index 0000000..ce8ee26 --- /dev/null +++ b/tests/TestHelpers/Initialize-PMUNativeLocalInstallation.ps1 @@ -0,0 +1,14 @@ +function Initialize-PMUNativeLocalInstallation { + param( + [object]$Fixture, + [string]$Destination + ) + + Assert-PMUNativeFixtureStore $Fixture.Root $Destination + Initialize-PMULocalPackage $Fixture '2.0.0-preview1' + PowerShellGet\Register-PSRepository -Name $Fixture.Repository -SourceLocation $Fixture.Feed -InstallationPolicy Trusted -ErrorAction Stop + # Bootstrap only, inside the verified disposable home; production never uses this command. + PowerShellGet\Install-Module -Name $Fixture.Name -RequiredVersion '2.0.0-preview1' -AllowPrerelease -Repository $Fixture.Repository -Scope CurrentUser -AcceptLicense -ErrorAction Stop + $manifest = Join-Path $Destination "$($Fixture.Name)/2.0.0/$($Fixture.Name).psd1" + $Fixture.Module = Get-Module -ListAvailable $manifest +} diff --git a/tests/TestHelpers/Initialize-PMUProviderMock.ps1 b/tests/TestHelpers/Initialize-PMUProviderMock.ps1 index 30ee7de..940cf8d 100644 --- a/tests/TestHelpers/Initialize-PMUProviderMock.ps1 +++ b/tests/TestHelpers/Initialize-PMUProviderMock.ps1 @@ -1,14 +1,34 @@ function Initialize-PMUProviderMock { + Mock Assert-NativeRepositoryBinding { Assert-RepositoryBinding $Operation.Plan $Operation.RepositoryName } + Mock Invoke-NativeHandoff { Invoke-PMUNativeHandoffMock $Operation $AlreadyInstalled } Mock Find-ProviderResource { - $script:repositoryFixture.Calls.Add([pscustomobject]@{ Kind = 'Find'; Repository = $Request.Repository; Credential = $Credential }) - [pscustomobject]@{ Name = $Request.Name; Version = [version]'2.0.0'; Prerelease = 'preview.2'; AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' }; Dependencies = @() } + $script:repositoryFixture.Calls.Add([pscustomobject]@{ + Kind = 'Find' + Repository = $Request.Repository + Credential = $Credential + }) + [pscustomobject]@{ + Name = $Request.Name + Version = [version]'2.0.0' + Prerelease = 'preview.2' + AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' } + Dependencies = @() + } } Mock Save-ProviderResource { - $script:repositoryFixture.Calls.Add([pscustomobject]@{ Kind = 'Save'; Repository = (Get-OperationRepositoryName $Operation); Credential = $Operation.Credential }) + $script:repositoryFixture.Calls.Add([pscustomobject]@{ + Kind = 'Save' + Repository = (Get-OperationRepositoryName $Operation) + Credential = $Operation.Credential + }) $null = Initialize-PMUTestPackage $Operation.StageRoot $Operation.Plan.TargetVersion $Operation.Plan.ModuleGuid $Operation.Plan.ModuleName } Mock Install-ProviderResource { - $script:repositoryFixture.Calls.Add([pscustomobject]@{ Kind = 'Install'; Repository = (Get-OperationRepositoryName $Operation); Credential = $Operation.Credential }) + $script:repositoryFixture.Calls.Add([pscustomobject]@{ + Kind = 'Install' + Repository = (Get-OperationRepositoryName $Operation) + Credential = $Operation.Credential + }) Invoke-PMUFixtureInstallation $Operation } } diff --git a/tests/TestHelpers/Initialize-PMURepositoryFixture.ps1 b/tests/TestHelpers/Initialize-PMURepositoryFixture.ps1 index 6b5a304..3cc632d 100644 --- a/tests/TestHelpers/Initialize-PMURepositoryFixture.ps1 +++ b/tests/TestHelpers/Initialize-PMURepositoryFixture.ps1 @@ -2,8 +2,12 @@ function Initialize-PMURepositoryFixture { param([string]$Root) $root = Join-Path $Root ([guid]::NewGuid().ToString('N')) $fixture = [pscustomobject]@{ - Root = $root; Modules = Join-Path $root 'Modules'; Data = Join-Path $root 'data' - Name = "PmuTarget$([guid]::NewGuid().ToString('N'))"; Guid = [guid]::NewGuid(); Module = $null + Root = $root + Modules = Join-Path $root 'Modules' + Data = Join-Path $root 'data' + Name = "PmuTarget$([guid]::NewGuid().ToString('N'))" + Guid = [guid]::NewGuid() + Module = $null Calls = [Collections.Generic.List[object]]::new() Repositories = Get-PMURepositoryEntry } diff --git a/tests/TestHelpers/Initialize-PMURepositoryMock.ps1 b/tests/TestHelpers/Initialize-PMURepositoryMock.ps1 index 8cebc1c..387d8f1 100644 --- a/tests/TestHelpers/Initialize-PMURepositoryMock.ps1 +++ b/tests/TestHelpers/Initialize-PMURepositoryMock.ps1 @@ -1,9 +1,18 @@ function Initialize-PMURepositoryMock { - Mock Resolve-ModuleInstallationRoot { if ($Scope -eq 'CurrentUser') { $script:repositoryFixture.Modules } else { "$($script:repositoryFixture.Root)/Other" } } + Mock Resolve-ModuleInstallationRoot { if ($Scope -eq 'CurrentUser') { + $script:repositoryFixture.Modules + } else { + "$($script:repositoryFixture.Root)/Other" + } } Mock Resolve-PMUDataRoot { $script:repositoryFixture.Data } - Mock Get-ProviderContext { [pscustomobject]@{ Name = 'Microsoft.PowerShell.PSResourceGet'; Version = [version]'1.2.0' } } + Mock Get-ProviderContext { [pscustomobject]@{ + Name = 'Microsoft.PowerShell.PSResourceGet' + Version = [version]'1.2.0' + } } Mock Get-PSResourceRepository { - if ($Name) { return $script:repositoryFixture.Repositories.Values | Where-Object Name -EQ $Name[0] } + if ($Name) { + return $script:repositoryFixture.Repositories.Values | Where-Object Name -EQ $Name[0] + } $script:repositoryFixture.Repositories.Values } Mock Register-PSResourceRepository { throw 'Production registration writes are prohibited.' } diff --git a/tests/TestHelpers/Initialize-PMUTestOperation.ps1 b/tests/TestHelpers/Initialize-PMUTestOperation.ps1 index a34b585..dec42f3 100644 --- a/tests/TestHelpers/Initialize-PMUTestOperation.ps1 +++ b/tests/TestHelpers/Initialize-PMUTestOperation.ps1 @@ -5,10 +5,21 @@ function Initialize-PMUTestOperation { $installed = Initialize-PMUTestPackage (Join-Path $Root 'Modules') '2.0.0-preview.1' $module = Get-Module -ListAvailable "$installed/FixtureModule.psd1" $identity = Get-ManifestIdentity "$installed/FixtureModule.psd1" - $observation = [pscustomobject]@{ FullVersion = $identity.Version.FullVersion; NumericVersion = '2.0.0'; Prerelease = 'preview.1'; Scope = 'CurrentUser'; Root = Join-Path $Root 'Modules'; ManifestPath = $identity.ManifestPath; Guid = $identity.Guid } + $observation = [pscustomobject]@{ + FullVersion = $identity.Version.FullVersion + NumericVersion = '2.0.0' + Prerelease = 'preview.1' + Scope = 'CurrentUser' + Root = Join-Path $Root 'Modules' + ManifestPath = $identity.ManifestPath + Guid = $identity.Guid + } $context = ConvertTo-PMUTestContext $module $identity $observation $plan = Register-UpdatePlan (ConvertTo-UpdatePlan $context) $context $false - $operation = ConvertTo-InstallationOperation $plan (Test-UpdatePlan $plan) @{ AcceptLicense = $true; TrustRepository = $true } $null + $operation = ConvertTo-InstallationOperation $plan (Test-UpdatePlan $plan) @{ + AcceptLicense = $true + TrustRepository = $true + } $null $operation | Add-Member TestContext $context $operation } diff --git a/tests/TestHelpers/Initialize-PMUTestPackage.ps1 b/tests/TestHelpers/Initialize-PMUTestPackage.ps1 index b5a76ed..3179391 100644 --- a/tests/TestHelpers/Initialize-PMUTestPackage.ps1 +++ b/tests/TestHelpers/Initialize-PMUTestPackage.ps1 @@ -1,5 +1,11 @@ function Initialize-PMUTestPackage { - param([string]$Root, [string]$Version, [guid]$Guid = 'a1111111-1111-1111-1111-111111111111', [string]$Name = 'FixtureModule') + param( + [string]$Root, + [string]$Version, + [guid]$Guid = 'a1111111-1111-1111-1111-111111111111', + [string]$Name = 'FixtureModule' + ) + $parts = @($Version -split '-', 2) + @('') $directory = Join-Path $Root "$Name/$($parts[0])" $null = New-Item $directory -ItemType Directory -Force diff --git a/tests/TestHelpers/Initialize-PMUUriFixture.ps1 b/tests/TestHelpers/Initialize-PMUUriFixture.ps1 index ff3c7a2..3fa3cf4 100644 --- a/tests/TestHelpers/Initialize-PMUUriFixture.ps1 +++ b/tests/TestHelpers/Initialize-PMUUriFixture.ps1 @@ -2,7 +2,13 @@ function Initialize-PMUUriFixture { param([string]$Root) [pscustomobject]@{ Root = Join-Path $Root ([guid]::NewGuid().ToString('N')) - Entries = @{}; Events = [Collections.Generic.List[string]]::new() - Source = [pscustomobject]@{ Name = $null; Uri = 'https://transport.invalid/v3/index.json'; ApiVersion = 'Unknown'; Trusted = $false } + Entries = @{} + Events = [Collections.Generic.List[string]]::new() + Source = [pscustomobject]@{ + Name = $null + Uri = 'https://transport.invalid/v3/index.json' + ApiVersion = 'Unknown' + Trusted = $false + } } } diff --git a/tests/TestHelpers/Initialize-PMUUriReadMock.ps1 b/tests/TestHelpers/Initialize-PMUUriReadMock.ps1 index b0a04a8..4b63859 100644 --- a/tests/TestHelpers/Initialize-PMUUriReadMock.ps1 +++ b/tests/TestHelpers/Initialize-PMUUriReadMock.ps1 @@ -1,6 +1,8 @@ function Initialize-PMUUriReadMock { Mock Get-PSResourceRepository { - if ($Name) { return @($script:uriFixture.Entries.Values | Where-Object Name -EQ $Name[0]) } + if ($Name) { + return @($script:uriFixture.Entries.Values | Where-Object Name -EQ $Name[0]) + } @($script:uriFixture.Entries.Values) } } diff --git a/tests/TestHelpers/Initialize-PMUUriRegisterMock.ps1 b/tests/TestHelpers/Initialize-PMUUriRegisterMock.ps1 index ca8e631..6963440 100644 --- a/tests/TestHelpers/Initialize-PMUUriRegisterMock.ps1 +++ b/tests/TestHelpers/Initialize-PMUUriRegisterMock.ps1 @@ -1,12 +1,28 @@ function Initialize-PMUUriRegisterMock { Mock Register-PSResourceRepository { - param($Name, $Uri, $ApiVersion = 'V3', $Priority, [switch]$Trusted, [switch]$PassThru) - if ($script:uriFixture.Entries.ContainsKey($Name)) { throw 'Existing registrations cannot be overwritten.' } + param( + $Name, + $Uri, + $ApiVersion = 'V3', + $Priority, + [switch]$Trusted, + [switch]$PassThru + ) + + if ($script:uriFixture.Entries.ContainsKey($Name)) { + throw 'Existing registrations cannot be overwritten.' + } @(Get-ChildItem $script:uriFixture.Root -Filter '*.json' -Recurse).Count | Should -BeGreaterThan 0 $script:uriFixture.Events.Add("Register:$Name") $script:uriFixture.Entries[$Name] = [pscustomobject]@{ - Name = $Name; Uri = [uri]$Uri; ApiVersion = "$ApiVersion"; Trusted = [bool]$Trusted; Priority = $Priority + Name = $Name + Uri = [uri]$Uri + ApiVersion = "$ApiVersion" + Trusted = [bool]$Trusted + Priority = $Priority + } + if ($PassThru) { + $script:uriFixture.Entries[$Name] } - if ($PassThru) { $script:uriFixture.Entries[$Name] } } } diff --git a/tests/TestHelpers/Initialize-PMUUriTransportMock.ps1 b/tests/TestHelpers/Initialize-PMUUriTransportMock.ps1 deleted file mode 100644 index e325b57..0000000 --- a/tests/TestHelpers/Initialize-PMUUriTransportMock.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -function Initialize-PMUUriTransportMock { - param([string]$Root) - $script:uriFixture = Initialize-PMUUriFixture $Root - Mock Resolve-RepositoryTransportRoot { $script:uriFixture.Root } - Initialize-PMUUriReadMock - Initialize-PMUUriRegisterMock - Initialize-PMUUriUnregisterMock -} diff --git a/tests/TestHelpers/Invoke-PMUChildProcess.ps1 b/tests/TestHelpers/Invoke-PMUChildProcess.ps1 index 492a63f..0a4cef1 100644 --- a/tests/TestHelpers/Invoke-PMUChildProcess.ps1 +++ b/tests/TestHelpers/Invoke-PMUChildProcess.ps1 @@ -1,5 +1,10 @@ function Invoke-PMUChildProcess { - param([string]$Root, [string]$Mode, [string]$Repository = 'OwnedFeed') + param( + [string]$Root, + [string]$Mode, + [string]$Repository = 'OwnedFeed' + ) + $start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-File', "$PSScriptRoot/Invoke-PMUProcessFixture.ps1", '-Root', $Root, '-Mode', $Mode, '-Repository', $Repository)) { $start.ArgumentList.Add($argument) diff --git a/tests/TestHelpers/Invoke-PMULocalProviderFixture.ps1 b/tests/TestHelpers/Invoke-PMULocalProviderFixture.ps1 index edfd7b4..ff6bd66 100644 --- a/tests/TestHelpers/Invoke-PMULocalProviderFixture.ps1 +++ b/tests/TestHelpers/Invoke-PMULocalProviderFixture.ps1 @@ -1,9 +1,14 @@ -param([string]$Root, [string]$ModulePath) +param( + [string]$Root, + [string]$ModulePath +) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' . "$PSScriptRoot/Initialize-PMUTestPackage.ps1" . "$PSScriptRoot/Initialize-PMULocalPackage.ps1" +. "$PSScriptRoot/Assert-PMUNativeFixtureStore.ps1" +. "$PSScriptRoot/Initialize-PMUNativeLocalInstallation.ps1" . "$PSScriptRoot/Get-PMUFixtureProviderRoot.ps1" . "$PSScriptRoot/Initialize-PMULocalFixture.ps1" . "$PSScriptRoot/Get-PMURegistrationSnapshot.ps1" @@ -18,8 +23,10 @@ $before = Get-PMURegistrationSnapshot $results = @() try { Register-PSResourceRepository -Name $fixture.Repository -Uri $fixture.Feed - foreach ($version in @('2.0.0-preview.2', '2.0.0-preview.3', '2.0.0', '3.0.0', '4.0.0.7')) { - if ($version -eq '2.0.0-preview.3') { Clear-PMUOwnedRegistration $fixture $before } + foreach ($version in @('2.0.0-preview2', '2.0.0-preview3', '2.0.0', '3.0.0', '4.0.0.7')) { + if ($version -eq '2.0.0-preview3') { + Clear-PMUOwnedRegistration $fixture $before + } $results += Invoke-PMULocalRelease $fixture $version } $results | ConvertTo-Json -Depth 6 -Compress diff --git a/tests/TestHelpers/Invoke-PMULocalProviderProcess.ps1 b/tests/TestHelpers/Invoke-PMULocalProviderProcess.ps1 index 7f04fa0..3efeb3d 100644 --- a/tests/TestHelpers/Invoke-PMULocalProviderProcess.ps1 +++ b/tests/TestHelpers/Invoke-PMULocalProviderProcess.ps1 @@ -1,5 +1,9 @@ function Invoke-PMULocalProviderProcess { - param([string]$Root, [string]$ModulePath) + param( + [string]$Root, + [string]$ModulePath + ) + $homePath = Join-Path $Root 'home' $null = New-Item $homePath -ItemType Directory -Force $start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) @@ -7,7 +11,18 @@ function Invoke-PMULocalProviderProcess { $start.ArgumentList.Add($argument) } $start.Environment['HOME'] = $homePath + foreach ($entry in @{ + XDG_CONFIG_HOME = '.config' + XDG_CACHE_HOME = '.cache' + XDG_DATA_HOME = '.local/share' + TMPDIR = 'tmp' + }.GetEnumerator()) { + $path = Join-Path $homePath $entry.Value + $null = New-Item $path -ItemType Directory -Force + $start.Environment[$entry.Key] = $path + } $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true - Receive-PMUChildProcess ([Diagnostics.Process]::Start($start)) + # This fixture performs five sequential native updates, not one process probe. + Receive-PMUChildProcess ([Diagnostics.Process]::Start($start)) -TimeoutMilliseconds 150000 } diff --git a/tests/TestHelpers/Invoke-PMULocalRelease.ps1 b/tests/TestHelpers/Invoke-PMULocalRelease.ps1 index 1f83bff..0ce908f 100644 --- a/tests/TestHelpers/Invoke-PMULocalRelease.ps1 +++ b/tests/TestHelpers/Invoke-PMULocalRelease.ps1 @@ -1,14 +1,26 @@ function Invoke-PMULocalRelease { - param([object]$Fixture, [string]$Version) + param( + [object]$Fixture, + [string]$Version + ) + Initialize-PMULocalPackage $Fixture $Version $selection = Get-PMULocalSelection $Fixture $Version $plan = Get-PSModuleUpdate -Module $Fixture.Module @selection -Policy @{ Channel = 'IncludePrerelease' } - if (-not $plan.RequiresLicenseAcceptance) { throw 'The real licensed fixture must expose its explicit license prerequisite.' } + if (-not $plan.RequiresLicenseAcceptance) { + throw 'The real licensed fixture must expose its explicit license prerequisite.' + } $preview = $plan | Install-PSModuleUpdate -WhatIf - if ($preview.Outcome -ne 'WhatIf') { throw 'Built-module WhatIf did not return the expected outcome.' } + if ($preview.Outcome -ne 'WhatIf') { + throw 'Built-module WhatIf did not return the expected outcome.' + } Assert-PMULicenseConsent $plan $result = $plan | Install-PSModuleUpdate -AcceptLicense -TrustRepository -Confirm:$false - if ($result.TargetVersion -cne $Version) { throw 'The local provider substituted a different target.' } - if ($null -ne $result.ActiveVersion) { throw 'An unloaded reference must not be reported as session-active.' } + if ($result.TargetVersion -cne $Version) { + throw 'The local provider substituted a different target.' + } + if ($null -ne $result.ActiveVersion) { + throw 'An unloaded reference must not be reported as session-active.' + } $result } diff --git a/tests/TestHelpers/Invoke-PMUNativeHandoffMock.ps1 b/tests/TestHelpers/Invoke-PMUNativeHandoffMock.ps1 new file mode 100644 index 0000000..ba3fe66 --- /dev/null +++ b/tests/TestHelpers/Invoke-PMUNativeHandoffMock.ps1 @@ -0,0 +1,15 @@ +function Invoke-PMUNativeHandoffMock { + param( + [object]$Operation, + [bool]$AlreadyInstalled + ) + + # Source-level public characterizations keep their original provider doubles. + # Native lock transfer, worker isolation and public built acceptance have separate process fixtures. + $tree = @(Initialize-StagedPackage $Operation) + if (-not $AlreadyInstalled) { + Initialize-Replacement $Operation $tree + Invoke-ProviderInstallation $Operation $tree + } + $tree +} diff --git a/tests/TestHelpers/Invoke-PMUProcessFixture.ps1 b/tests/TestHelpers/Invoke-PMUProcessFixture.ps1 index d53d080..28369a3 100644 --- a/tests/TestHelpers/Invoke-PMUProcessFixture.ps1 +++ b/tests/TestHelpers/Invoke-PMUProcessFixture.ps1 @@ -1,4 +1,8 @@ -param([string]$Root, [ValidateSet('Update', 'Crash', 'Recover', 'RecoverBackup', 'Hold', 'Attempt', 'Loaded')][string]$Mode, [ValidateSet('OwnedFeed', 'AlternateFeed')][string]$Repository = 'OwnedFeed') +param( + [string]$Root, + [ValidateSet('Update', 'Crash', 'Recover', 'RecoverBackup', 'Hold', 'Attempt', 'Loaded')][string]$Mode, + [ValidateSet('OwnedFeed', 'AlternateFeed')][string]$Repository = 'OwnedFeed' +) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -7,27 +11,76 @@ $script:fixtureMode = $Mode . "$PSScriptRoot/Import-PMUTestSource.ps1" Set-Item Function:\Resolve-ModuleInstallationRoot { param($Scope) - if ($Scope -eq 'CurrentUser') { return Join-Path $script:fixtureRoot 'Modules' } + if ($Scope -eq 'CurrentUser') { + return Join-Path $script:fixtureRoot 'Modules' + } Join-Path $script:fixtureRoot 'OtherModules' } Set-Item Function:\Resolve-PMUDataRoot { param($Scope) - if ($Scope -ne 'CurrentUser') { throw 'The process fixture prohibits AllUsers mutation.' } + if ($Scope -ne 'CurrentUser') { + throw 'The process fixture prohibits AllUsers mutation.' + } Join-Path $script:fixtureRoot 'data' } -Set-Item Function:\Get-ProviderContext { [pscustomobject]@{ Name = 'Microsoft.PowerShell.PSResourceGet'; Version = [version]'1.2.0' } } +Set-Item Function:\Get-ProviderContext { [pscustomobject]@{ + Name = 'Microsoft.PowerShell.PSResourceGet' + Version = [version]'1.2.0' + } } +Set-Item Function:\Assert-NativeRepositoryBinding { + param($Operation) + Assert-RepositoryBinding $Operation.Plan $Operation.RepositoryName +} +Set-Item Function:\Invoke-NativeHandoff { + param( + $Operation, + $AlreadyInstalled + ) + + Invoke-PMUNativeHandoffMock $Operation $AlreadyInstalled +} Set-Item Function:\Get-RepositorySnapshot { - param($Name, $ExpectedUri) - $endpoints = @{ OwnedFeed = 'https://example.invalid/feed'; AlternateFeed = 'https://example.invalid/alternate' } - if (-not $endpoints.ContainsKey($Name)) { throw 'Unexpected fixture repository.' } - if ($ExpectedUri -and $ExpectedUri -ne [uri]$endpoints[$Name]) { throw 'Unexpected fixture endpoint.' } - [pscustomobject]@{ Name = $Name; Uri = $endpoints[$Name]; ApiVersion = 'V3'; Trusted = $true } + param( + $Name, + $ExpectedUri + ) + + $endpoints = @{ + OwnedFeed = 'https://example.invalid/feed' + AlternateFeed = 'https://example.invalid/alternate' + } + if (-not $endpoints.ContainsKey($Name)) { + throw 'Unexpected fixture repository.' + } + if ($ExpectedUri -and $ExpectedUri -ne [uri]$endpoints[$Name]) { + throw 'Unexpected fixture endpoint.' + } + [pscustomobject]@{ + Name = $Name + Uri = $endpoints[$Name] + ApiVersion = 'V3' + Trusted = $true + } } Set-Item Function:\Find-ProviderResource { - param($Request, [pscredential]$Credential) - if ($Request.Name -ne 'FixtureModule') { throw 'Unexpected fixture package.' } - if ($Credential) { throw 'Process fixtures never accept credentials.' } - [pscustomobject]@{ Name = 'FixtureModule'; Version = [version]'2.0.0'; Prerelease = 'preview.2'; AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' }; Dependencies = @() } + param( + $Request, + [pscredential]$Credential + ) + + if ($Request.Name -ne 'FixtureModule') { + throw 'Unexpected fixture package.' + } + if ($Credential) { + throw 'Process fixtures never accept credentials.' + } + [pscustomobject]@{ + Name = 'FixtureModule' + Version = [version]'2.0.0' + Prerelease = 'preview.2' + AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' } + Dependencies = @() + } } Set-Item Function:\Save-ProviderResource { param($Operation) @@ -38,7 +91,9 @@ Set-Item Function:\Save-ProviderResource { Set-Item Function:\Install-ProviderResource { param($Operation) Remove-Item -LiteralPath $Operation.Plan.TargetInstalledPath -Recurse -Force - if ($script:fixtureMode -eq 'Crash') { [Environment]::Exit(73) } + if ($script:fixtureMode -eq 'Crash') { + [Environment]::Exit(73) + } if ($script:fixtureMode -eq 'Hold') { [IO.File]::WriteAllText((Join-Path $script:fixtureRoot 'mutation-started'), '') Start-Sleep -Seconds 120 @@ -48,7 +103,9 @@ Set-Item Function:\Install-ProviderResource { [IO.File]::AppendAllText((Join-Path $script:fixtureRoot 'installs.txt'), "installed`n") } $module = Get-Module -ListAvailable (Join-Path $Root 'Modules/FixtureModule/1.0.0/FixtureModule.psd1') -if ($Mode -eq 'RecoverBackup') { $module = Get-PMUBackupModuleReference $Root } +if ($Mode -eq 'RecoverBackup') { + $module = Get-PMUBackupModuleReference $Root +} if ($Mode -eq 'Loaded') { $module = Import-Module (Join-Path $Root 'Modules/FixtureModule/2.0.0/FixtureModule.psd1') -PassThru } @@ -57,9 +114,14 @@ if ($Mode -eq 'Update') { [IO.File]::WriteAllText((Join-Path $Root "ready-$PID"), '') $deadline = [datetime]::UtcNow.AddSeconds(30) while (-not (Test-Path -LiteralPath (Join-Path $Root 'start'))) { - if ([datetime]::UtcNow -gt $deadline) { throw 'Fixture start gate timed out.' } + if ([datetime]::UtcNow -gt $deadline) { + throw 'Fixture start gate timed out.' + } Start-Sleep -Milliseconds 50 } } -try { Invoke-PMUProcessUpdate $plan $Mode $module } -catch { ConvertTo-PMUProcessFailure $_ $Mode } +try { + Invoke-PMUProcessUpdate $plan $Mode $module +} catch { + ConvertTo-PMUProcessFailure $_ $Mode +} diff --git a/tests/TestHelpers/Invoke-PMUProcessUpdate.ps1 b/tests/TestHelpers/Invoke-PMUProcessUpdate.ps1 index 2b8ef42..8255bbb 100644 --- a/tests/TestHelpers/Invoke-PMUProcessUpdate.ps1 +++ b/tests/TestHelpers/Invoke-PMUProcessUpdate.ps1 @@ -1,5 +1,10 @@ function Invoke-PMUProcessUpdate { - param([object]$Plan, [string]$Mode, [Management.Automation.PSModuleInfo]$Module) + param( + [object]$Plan, + [string]$Mode, + [Management.Automation.PSModuleInfo]$Module + ) + $result = $Plan | Install-PSModuleUpdate -Confirm:$false if ($Mode -eq 'Loaded') { if (-not [object]::ReferenceEquals($Module, (Get-Module FixtureModule))) { diff --git a/tests/TestHelpers/Invoke-PMURepositoryRoundTrip.ps1 b/tests/TestHelpers/Invoke-PMURepositoryRoundTrip.ps1 index 0791f59..4903a0d 100644 --- a/tests/TestHelpers/Invoke-PMURepositoryRoundTrip.ps1 +++ b/tests/TestHelpers/Invoke-PMURepositoryRoundTrip.ps1 @@ -1,12 +1,29 @@ function Invoke-PMURepositoryRoundTrip { - param([object]$Fixture, [string]$RepositoryChoice, [string]$Selector, [pscredential]$Credential) + param( + [object]$Fixture, + [string]$RepositoryChoice, + [string]$Selector, + [pscredential]$Credential + ) + $repository = $Fixture.Repositories[$RepositoryChoice] - $parameters = @{ Module = $Fixture.Module; Policy = @{ Channel = 'IncludePrerelease' } } - if ($Selector -eq 'Uri') { $parameters.RepositoryUri = $repository.Uri } - else { $parameters.Repository = $repository.Name } + $parameters = @{ + Module = $Fixture.Module + Policy = @{ Channel = 'IncludePrerelease' } + } + if ($Selector -eq 'Uri') { + $parameters.RepositoryUri = $repository.Uri + } else { + $parameters.Repository = $repository.Name + } $authentication = @{} - if ($Credential) { $authentication.Credential = $Credential } + if ($Credential) { + $authentication.Credential = $Credential + } $plan = Get-PSModuleUpdate @parameters @authentication $result = $plan | Install-PSModuleUpdate @authentication -TrustRepository -Confirm:$false - [pscustomobject]@{ Plan = $plan; Result = $result } + [pscustomobject]@{ + Plan = $plan + Result = $result + } } diff --git a/tests/TestHelpers/NativeWorkerLifetime.TestSupport.ps1 b/tests/TestHelpers/NativeWorkerLifetime.TestSupport.ps1 new file mode 100644 index 0000000..6709723 --- /dev/null +++ b/tests/TestHelpers/NativeWorkerLifetime.TestSupport.ps1 @@ -0,0 +1,89 @@ +function Initialize-PMUNativeLeaseModule { + param([string]$Root) + + $projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $entry = Join-Path $projectRoot 'src/private/installation/Invoke-NativeWorker.ps1' + $sources = @($entry) + @(Get-PMUTestSourcePath $entry) + $imports = $sources | ForEach-Object { ". '$($_.Replace("'", "''"))'" } + $fixture = @' +${function:Initialize-NativeProvider} = { + param([object]$Operation) +} + +${function:Assert-NativeSelectedInstallation} = { + param([object]$Context) +} + +# Only the provider boundary is simulated; ownership and journal checks stay real. +${function:Invoke-OwnedNativeUpdate} = { + param([object]$Context) + + $root = $Context.Operation.NativeContext.Root + $release = Join-Path $root 'release-writer' + $deadline = [DateTime]::UtcNow.AddSeconds(20) + Write-NativeWorkerMessage ([pscustomobject]@{ Phase = 'WRITING' }) + while (-not (Test-Path -LiteralPath $release)) { + if ([DateTime]::UtcNow -gt $deadline) { + throw 'Disposable native writer was not released.' + } + Start-Sleep -Milliseconds 20 + } + [IO.File]::WriteAllText((Join-Path $root 'writer-finished'), 'complete') + [pscustomobject]@{ Phase = 'RESULT'; Success = $true } +} + +Export-ModuleMember -Function @() +'@ + $path = Join-Path $Root 'lease-worker.psm1' + Set-Content -LiteralPath $path -Value (($imports -join "`n") + "`n" + $fixture) -Encoding utf8 + $path +} + +function Invoke-PMUNativeLeaseApprover { + $start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) + foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$null = [Console]::In.ReadLine()')) { + $start.ArgumentList.Add($argument) + } + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + [Diagnostics.Process]::Start($start) +} + +function Invoke-PMUNativeLeaseProbe { + param( + [object]$Operation, + [string]$Root + ) + + $parent = Invoke-PMUNativeLeaseApprover + $worker = $null + try { + $context = [pscustomobject]@{ + ModulePath = Initialize-PMUNativeLeaseModule $Root + Operation = $Operation + ParentPid = $parent.Id + ParentStarted = Get-RepositoryProcessStart $parent.Id + Token = Get-NativeJournalToken $Operation + } + $worker = [Diagnostics.Process]::Start((Initialize-NativeWorkerStartInfo $context)) + $serialized = [Management.Automation.PSSerializer]::Serialize($context, 30) + $worker.StandardInput.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($serialized))) + [pscustomobject]@{ Parent = $parent; Worker = $worker; Error = $worker.StandardError.ReadToEndAsync() } + } catch { + Close-PMUChildProcess @(@($parent, $worker) | Where-Object { $_ }) + throw + } +} + +function Receive-PMUNativeLeaseMessage { + param([object]$Probe) + + $message = $Probe.Worker.StandardOutput.ReadLineAsync() + if (-not $message.Wait(15000)) { + throw 'Disposable native worker did not produce its next protocol message.' + } + if (-not $message.Result) { + throw "Disposable native worker closed its protocol: $($Probe.Error.GetAwaiter().GetResult())" + } + Read-NativeWorkerMessage ([IO.StringReader]::new($message.Result)) +} diff --git a/tests/TestHelpers/Receive-PMUChildProcess.Tests.ps1 b/tests/TestHelpers/Receive-PMUChildProcess.Tests.ps1 new file mode 100644 index 0000000..ae9dc4e --- /dev/null +++ b/tests/TestHelpers/Receive-PMUChildProcess.Tests.ps1 @@ -0,0 +1,46 @@ +BeforeAll { + . "$PSScriptRoot/Receive-PMUChildProcess.ps1" + . "$PSScriptRoot/Close-PMUChildProcess.ps1" +} + +Describe 'Receive-PMUChildProcess redirected streams' -Tag 'PMU', 'PMUChildCapture' { + BeforeEach { + $script:start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) + foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command')) { + $start.ArgumentList.Add($argument) + } + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + } + + It 'drains both full pipes without deadlocking and preserves output and a nonzero exit code' { + $command = @' +[Console]::Out.Write('o' * 262144) +[Console]::Error.Write('e' * 262144) +exit 7 +'@ + $start.ArgumentList.Add($command) + $child = [Diagnostics.Process]::Start($start) + try { + $result = Receive-PMUChildProcess $child + + $result.ExitCode | Should -Be 7 + $result.Output | Should -BeExactly ('o' * 262144) + $result.Error | Should -BeExactly ('e' * 262144) + } finally { + Close-PMUChildProcess $child + } + } + + It 'enforces an explicitly selected deadline and terminates only its fixture process tree' { + $start.ArgumentList.Add('Start-Sleep -Seconds 2') + $child = [Diagnostics.Process]::Start($start) + try { + { Receive-PMUChildProcess $child -TimeoutMilliseconds 100 } | + Should -Throw '*Fixture child process timed out.*' + $child.WaitForExit(5000) | Should -BeTrue + } finally { + Close-PMUChildProcess $child + } + } +} diff --git a/tests/TestHelpers/Receive-PMUChildProcess.ps1 b/tests/TestHelpers/Receive-PMUChildProcess.ps1 index 840497c..ab0c93c 100644 --- a/tests/TestHelpers/Receive-PMUChildProcess.ps1 +++ b/tests/TestHelpers/Receive-PMUChildProcess.ps1 @@ -1,8 +1,17 @@ function Receive-PMUChildProcess { - param([Diagnostics.Process]$Process) - if (-not $Process.WaitForExit(45000)) { + param( + [Diagnostics.Process]$Process, + [ValidateRange(1, 2147483647)][int]$TimeoutMilliseconds = 45000 + ) + $output = $Process.StandardOutput.ReadToEndAsync() + $errorOutput = $Process.StandardError.ReadToEndAsync() + if (-not $Process.WaitForExit($TimeoutMilliseconds)) { $Process.Kill($true) throw 'Fixture child process timed out.' } - [pscustomobject]@{ ExitCode = $Process.ExitCode; Output = $Process.StandardOutput.ReadToEnd(); Error = $Process.StandardError.ReadToEnd() } + [pscustomobject]@{ + ExitCode = $Process.ExitCode + Output = $output.GetAwaiter().GetResult() + Error = $errorOutput.GetAwaiter().GetResult() + } } diff --git a/tests/TestHelpers/Wait-PMUFixtureSignal.ps1 b/tests/TestHelpers/Wait-PMUFixtureSignal.ps1 index 01abcaf..0694dd5 100644 --- a/tests/TestHelpers/Wait-PMUFixtureSignal.ps1 +++ b/tests/TestHelpers/Wait-PMUFixtureSignal.ps1 @@ -1,8 +1,15 @@ function Wait-PMUFixtureSignal { - param([string]$Root, [string]$Pattern, [int]$Count = 1) + param( + [string]$Root, + [string]$Pattern, + [int]$Count = 1 + ) + $deadline = [datetime]::UtcNow.AddSeconds(30) while (@(Get-ChildItem -LiteralPath $Root -Filter $Pattern).Count -lt $Count) { - if ([datetime]::UtcNow -gt $deadline) { throw 'Fixture readiness timed out.' } + if ([datetime]::UtcNow -gt $deadline) { + throw 'Fixture readiness timed out.' + } Start-Sleep -Milliseconds 50 } } diff --git a/tests/integration/LocalProvider.Integration.Tests.ps1 b/tests/integration/LocalProvider.Integration.Tests.ps1 index cbc727a..edfa973 100644 --- a/tests/integration/LocalProvider.Integration.Tests.ps1 +++ b/tests/integration/LocalProvider.Integration.Tests.ps1 @@ -4,7 +4,7 @@ BeforeAll { $root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $script:builtModule = Join-Path $root 'dist/PSModuleUpdate/PSModuleUpdate.psd1' } -Describe 'PMU isolated PSResourceGet qualification' -Tag 'Integration', 'PMULocalProvider' { +Describe 'PMU isolated native Update-Module qualification' -Tag 'Integration', 'PMULocalProvider' { It 'performs licensed offline prerelease progression, stable promotion and four-component installation' -Skip:(-not $IsMacOS) { $fixture = Join-Path $TestDrive 'local-provider' $result = Invoke-PMULocalProviderProcess $fixture $builtModule @@ -12,7 +12,7 @@ Describe 'PMU isolated PSResourceGet qualification' -Tag 'Integration', 'PMULoca $result.Output | Should -Match 'from file:/{3}.+ into CurrentUser' $results = @($result.Output.Trim().Split("`n")[-1] | ConvertFrom-Json) $results.Count | Should -Be 5 - @($results.InstalledVersion) | Should -Be @('2.0.0-preview.2', '2.0.0-preview.3', '2.0.0', '3.0.0', '4.0.0.7') + @($results.InstalledVersion) | Should -Be @('2.0.0-preview2', '2.0.0-preview3', '2.0.0', '3.0.0', '4.0.0.7') @($results.Outcome | Select-Object -Unique) | Should -Be @('Installed') } } diff --git a/tests/integration/ProcessRecovery.Tests.ps1 b/tests/integration/ProcessRecovery.Tests.ps1 index a5c4f52..71be66d 100644 --- a/tests/integration/ProcessRecovery.Tests.ps1 +++ b/tests/integration/ProcessRecovery.Tests.ps1 @@ -21,7 +21,7 @@ Describe 'PMU backup-only public recovery' -Tag 'PMUBackupReference' { Test-Path "$root/installs.txt" | Should -BeFalse } } -Describe 'PMU process recovery and concurrency' -Tag 'PMU' { +Describe 'PMU process recovery and concurrency' -Tag 'PMU', 'PMUProcessRecovery' { BeforeEach { $script:processRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) $null = Initialize-PMUTestPackage "$processRoot/Modules" '1.0.0' @@ -54,7 +54,9 @@ Describe 'PMU process recovery and concurrency' -Tag 'PMU' { ($b.Output | ConvertFrom-Json).RepositoryName | Should -Be $SecondRepository @((($a.Output | ConvertFrom-Json).Outcome), (($b.Output | ConvertFrom-Json).Outcome)) | Sort-Object | Should -Be @('AlreadyInstalled', 'Installed') @(Get-Content "$processRoot/installs.txt").Count | Should -Be 1 - } finally { Close-PMUChildProcess @($first, $second) } + } finally { + Close-PMUChildProcess @($first, $second) + } } It 'times out a real competing process, releases the OS lock after hard kill, and publicly recovers' { $holder = Invoke-PMUChildProcess $processRoot 'Hold' @@ -68,7 +70,9 @@ Describe 'PMU process recovery and concurrency' -Tag 'PMU' { $recovered = Receive-PMUChildProcess (Invoke-PMUChildProcess $processRoot 'Recover') $recovered.ExitCode | Should -Be 0 -Because $recovered.Error ($recovered.Output | ConvertFrom-Json).Outcome | Should -Be 'Recovered' - } finally { Close-PMUChildProcess @($holder) } + } finally { + Close-PMUChildProcess @($holder) + } } It 'keeps the actual loaded module active while reporting replaced files require restart' { [IO.File]::WriteAllText("$processRoot/Modules/FixtureModule/2.0.0/FixtureModule.psm1", '$script:FixtureValue = 1') diff --git a/tests/integration/RepositoryBinding.Tests.ps1 b/tests/integration/RepositoryBinding.Tests.ps1 index 4529c72..e6e6b99 100644 --- a/tests/integration/RepositoryBinding.Tests.ps1 +++ b/tests/integration/RepositoryBinding.Tests.ps1 @@ -9,20 +9,36 @@ Describe 'PMU standalone target and exact repository binding' -Tag 'PMU', 'PMURe Initialize-PMUProviderMock } It 'updates an unloaded dependency-free installed target by using ' -ForEach @( - @{ Selector = 'Name'; RepositoryChoice = 'Public' } - @{ Selector = 'Uri'; RepositoryChoice = 'Public' } - @{ Selector = 'Name'; RepositoryChoice = 'Private' } - @{ Selector = 'Uri'; RepositoryChoice = 'Private' } + @{ + Selector = 'Name' + RepositoryChoice = 'Public' + } + @{ + Selector = 'Uri' + RepositoryChoice = 'Public' + } + @{ + Selector = 'Name' + RepositoryChoice = 'Private' + } + @{ + Selector = 'Uri' + RepositoryChoice = 'Private' + } ) { $credential = $null - if ($RepositoryChoice -eq 'Private') { $credential = [pscredential]::new('fixture-credential-user', [securestring]::new()) } + if ($RepositoryChoice -eq 'Private') { + $credential = [pscredential]::new('fixture-credential-user', [securestring]::new()) + } $bundle = Invoke-PMURepositoryRoundTrip $repositoryFixture $RepositoryChoice $Selector $credential $bundle.Result.Outcome | Should -Be Installed $bundle.Plan.ModuleName | Should -Be $repositoryFixture.Name @($repositoryFixture.Module.RequiredModules).Count | Should -Be 0 @(Get-Module -Name $repositoryFixture.Name).Count | Should -Be 0 @($repositoryFixture.Calls.Repository | Select-Object -Unique) | Should -Be @($repositoryFixture.Repositories[$RepositoryChoice].Name) - foreach ($call in $repositoryFixture.Calls) { [object]::ReferenceEquals($call.Credential, $credential) | Should -BeTrue } + foreach ($call in $repositoryFixture.Calls) { + [object]::ReferenceEquals($call.Credential, $credential) | Should -BeTrue + } (ConvertTo-Json $bundle -Depth 12) | Should -Not -Match 'PSCredential|SecureString|fixture-credential-user' Should -Invoke Register-PSResourceRepository -Times 0 Should -Invoke Unregister-PSResourceRepository -Times 0 @@ -38,8 +54,16 @@ Describe 'PMU standalone target and exact repository binding' -Tag 'PMU', 'PMURe $script:bindingFindCount = 0 Mock Find-ProviderResource { $script:bindingFindCount++ - if ($script:bindingFindCount -eq 3) { $script:repositoryFixture.Repositories.Public.Uri = [uri]'https://changed-before-save.invalid/v3/index.json' } - [pscustomobject]@{ Name = $Request.Name; Version = [version]'2.0.0'; Prerelease = 'preview.2'; AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' }; Dependencies = @() } + if ($script:bindingFindCount -eq 3) { + $script:repositoryFixture.Repositories.Public.Uri = [uri]'https://changed-before-save.invalid/v3/index.json' + } + [pscustomobject]@{ + Name = $Request.Name + Version = [version]'2.0.0' + Prerelease = 'preview.2' + AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' } + Dependencies = @() + } } { Invoke-PMURepositoryRoundTrip $repositoryFixture Public Uri $null } | Should -Throw -ErrorId 'PMU.RepositoryChanged*' Should -Invoke Save-ProviderResource -Times 0 diff --git a/tests/integration/UriBridge.Tests.ps1 b/tests/integration/UriBridge.Tests.ps1 index 2243bc9..1f3ef65 100644 --- a/tests/integration/UriBridge.Tests.ps1 +++ b/tests/integration/UriBridge.Tests.ps1 @@ -34,8 +34,9 @@ Describe 'PMU temporary URI public contract' -Tag 'PMUUriBridge' { { Assert-OwnedRepositoryConfiguration $uriFixture.Entries[$lease.Name] $lease.Receipt } | Should -Throw -ErrorId 'PMU.RepositoryRecoveryRequired*' $uriFixture.Entries[$lease.Name].ApiVersion = 'V3' + } finally { + Close-RepositoryTransport $lease } - finally { Close-RepositoryTransport $lease } } It 'does not register or rediscover an unregistered URI during Install WhatIf' { $plan = Get-PSModuleUpdate -Module $repositoryFixture.Module -RepositoryUri $uriFixture.Source.Uri -Policy @{ Channel = 'IncludePrerelease' } @@ -63,7 +64,9 @@ Describe 'PMU temporary URI public contract' -Tag 'PMUUriBridge' { $credential = [pscredential]::new('bridge-fixture-user', [securestring]::new()) $plan = Get-PSModuleUpdate -Module $repositoryFixture.Module -RepositoryUri $uriFixture.Source.Uri -Credential $credential -Policy @{ Channel = 'IncludePrerelease' } $result = $plan | Install-PSModuleUpdate -Credential $credential -Confirm:$false -TrustRepository - foreach ($call in $repositoryFixture.Calls) { [object]::ReferenceEquals($call.Credential, $credential) | Should -BeTrue } + foreach ($call in $repositoryFixture.Calls) { + [object]::ReferenceEquals($call.Credential, $credential) | Should -BeTrue + } @($repositoryFixture.Calls.Repository | Select-Object -Unique).Count | Should -Be 2 ConvertTo-Json @($plan, $result) -Depth 12 | Should -Not -Match 'bridge-fixture-user|PSCredential|pmu-uri-' Should -Invoke Register-PSResourceRepository -Times 2 -ParameterFilter { -not $CredentialInfo } @@ -76,7 +79,13 @@ Describe 'PMU temporary URI public contract' -Tag 'PMUUriBridge' { Should -Invoke Find-ProviderResource -Times 0 } It 'rejects credentials embedded in registered source metadata before using it' { - $uriFixture.Entries['Leaky'] = [pscustomobject]@{ Name = 'Leaky'; Uri = [uri]'https://user:fixture-secret@transport.invalid/v3/index.json'; Trusted = $false; ApiVersion = 'V3'; Priority = 50 } + $uriFixture.Entries['Leaky'] = [pscustomobject]@{ + Name = 'Leaky' + Uri = [uri]'https://user:fixture-secret@transport.invalid/v3/index.json' + Trusted = $false + ApiVersion = 'V3' + Priority = 50 + } $failure = { Get-PSModuleUpdate -Module $repositoryFixture.Module -Repository Leaky } | Should -Throw -ErrorId 'PMU.InvalidRequest*' -PassThru "$failure" | Should -Not -Match 'fixture-secret|user:' Should -Invoke Register-PSResourceRepository -Times 0 diff --git a/tests/private/identity/ConvertTo-InstallationObservation.Tests.ps1 b/tests/private/identity/ConvertTo-InstallationObservation.Tests.ps1 new file mode 100644 index 0000000..8f4b42b --- /dev/null +++ b/tests/private/identity/ConvertTo-InstallationObservation.Tests.ps1 @@ -0,0 +1,48 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'ConvertTo-InstallationObservation' -Tag 'PMU' { + BeforeEach { + $script:identity = [pscustomobject]@{ + Version = [pscustomobject]@{ + FullVersion = '2.0.0-preview.1' + NumericVersion = '2.0.0' + Prerelease = 'preview.1' + } + Guid = [guid]'cc2a0585-ae0a-49df-a47a-2e70de88428f' + ManifestPath = 'private-backup-manifest' + ActiveVersion = '1.0.0' + Credential = 'private-test-value' + } + } + + It 'allow-lists the seven observation fields in contract order without leaking identity metadata' { + $observation = ConvertTo-InstallationObservation $script:identity 'AllUsers' 'CanonicalRoot' 'TargetManifest' + + $observation.PSObject.Properties.Name | Should -Be @( + 'FullVersion', 'NumericVersion', 'Prerelease', 'Scope', 'Root', 'ManifestPath', 'Guid' + ) + $observation.FullVersion | Should -BeExactly '2.0.0-preview.1' + $observation.NumericVersion | Should -BeExactly '2.0.0' + $observation.Prerelease | Should -BeExactly 'preview.1' + $observation.Scope | Should -BeExactly 'AllUsers' + $observation.Root | Should -BeExactly 'CanonicalRoot' + $observation.ManifestPath | Should -BeExactly 'TargetManifest' + $observation.Guid | Should -Be $script:identity.Guid + $observation.Guid | Should -BeOfType ([guid]) + } + + It 'preserves null prerelease and location values without string coercion' { + $script:identity.Version.Prerelease = $null + + $observation = ConvertTo-InstallationObservation $script:identity $null $null $null + + $observation.Prerelease | Should -BeNullOrEmpty + ($null -eq $observation.Prerelease) | Should -BeTrue + ($null -eq $observation.Scope) | Should -BeTrue + ($null -eq $observation.Root) | Should -BeTrue + ($null -eq $observation.ManifestPath) | Should -BeTrue + $observation.FullVersion | Should -BeOfType ([string]) + } +} diff --git a/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 b/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 index 19a3192..f3e8c9e 100644 --- a/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 +++ b/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 @@ -1,16 +1,74 @@ -BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} Describe 'Get-InstalledModuleIdentityList' -Tag 'PMU' { + BeforeEach { + $script:installationRoot = Join-Path $TestDrive ([guid]::NewGuid()) + $null = New-Item $script:installationRoot -ItemType Directory + Mock Resolve-ModuleInstallationRoot { $script:installationRoot } + } It 'reads the manifest prerelease, not the numeric directory name' { - $null = Initialize-PMUTestPackage $TestDrive '2.0.0-preview.10' - Mock Resolve-ModuleInstallationRoot { $TestDrive } + $null = Initialize-PMUTestPackage $script:installationRoot '2.0.0-preview.10' $identity = Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' $identity.FullVersion | Should -Be '2.0.0-preview.10' $identity.Scope | Should -Be 'AllUsers' } It 'rejects a directory whose name differs from the manifest numeric version' { - $path = Initialize-PMUTestPackage $TestDrive '2.0.0' + $path = Initialize-PMUTestPackage $script:installationRoot '2.0.0' Rename-Item -LiteralPath $path -NewName '9.9.9' - Mock Resolve-ModuleInstallationRoot { $TestDrive } { Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' } | Should -Throw -ErrorId 'PMU.UnsupportedInstallation' } + + It 'discovers a version beside flat-installation resources without changing the layout' -Tag 'InstalledLayout' { + $path = Initialize-PMUTestPackage $script:installationRoot '0.1.0' + $moduleRoot = Split-Path -Parent $path + Copy-Item "$path/FixtureModule.psd1" $moduleRoot + $null = New-Item "$moduleRoot/en-US", "$moduleRoot/resources" -ItemType Directory + $identity = @(Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers') + $identity.Count | Should -Be 1 + $identity[0].FullVersion | Should -Be '0.1.0' + $identity[0].ManifestPath | Should -Be (Join-Path $path 'FixtureModule.psd1') + "$moduleRoot/en-US", "$moduleRoot/resources", "$moduleRoot/FixtureModule.psd1" | + ForEach-Object { $_ | Should -Exist } + } + + It 'still rejects a numeric version directory without a manifest' -Tag 'InstalledLayout' { + $null = New-Item "$script:installationRoot/FixtureModule/0.1.0" -ItemType Directory -Force + { Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' } | + Should -Throw -ErrorId 'PMU.ModuleIdentityMismatch' + } + + It 'still rejects a nonnumeric directory containing a module manifest' -Tag 'InstalledLayout' { + $path = Initialize-PMUTestPackage $script:installationRoot '0.1.0' + Rename-Item -LiteralPath $path -NewName 'unexpected' + { Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' } | + Should -Throw -ErrorId 'PMU.UnsupportedInstallation' + } + + It 'excludes the exact target before manifest validation and preserves remaining candidate order' { + $excluded = Initialize-PMUTestPackage $script:installationRoot '1.0.0' + $newer = Initialize-PMUTestPackage $script:installationRoot '3.0.0' + $older = Initialize-PMUTestPackage $script:installationRoot '2.0.0' + Set-Content (Join-Path $excluded 'FixtureModule.psd1') 'not a manifest' + $script:orderedCandidates = Get-Item $excluded, $newer, $older + Mock Get-ChildItem { $script:orderedCandidates } + + $observations = @(Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' $excluded) + + $observations.FullVersion | Should -Be @('3.0.0', '2.0.0') + $observations.ManifestPath | Should -Be @( + (Join-Path $newer 'FixtureModule.psd1') + (Join-Path $older 'FixtureModule.psd1') + ) + } + + It 'does not exclude a candidate whose path differs only by case' { + $path = Initialize-PMUTestPackage $script:installationRoot '2.0.0' + $excluded = $path.ToUpperInvariant() + + $observations = @(Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' $excluded) + + $observations.Count | Should -Be 1 + $observations[0].ManifestPath | Should -BeExactly (Join-Path $path 'FixtureModule.psd1') + } } diff --git a/tests/private/identity/Get-ManifestIdentity.Tests.ps1 b/tests/private/identity/Get-ManifestIdentity.Tests.ps1 index 2238bc4..51d03b4 100644 --- a/tests/private/identity/Get-ManifestIdentity.Tests.ps1 +++ b/tests/private/identity/Get-ManifestIdentity.Tests.ps1 @@ -21,4 +21,18 @@ Describe 'Get-ManifestIdentity' -Tag 'PMU' { Remove-Item "$path/FixtureModule.psm1" { Get-ManifestIdentity "$path/FixtureModule.psd1" } | Should -Throw -ErrorId 'PMU.ModuleIdentityMismatch' } + It 'preserves canonical identity fields and prerelease version types' { + $path = Initialize-PMUTestPackage $TestDrive '1.2.3-preview.1' + $manifest = Join-Path $path 'FixtureModule.psd1' + $identity = Get-ManifestIdentity (Join-Path $path './FixtureModule.psd1') + + $identity | Should -BeOfType [pscustomobject] + @($identity.PSObject.Properties.Name) | Should -Be @('Name', 'Guid', 'Version', 'ManifestPath') + $identity.Name | Should -BeExactly 'FixtureModule' + $identity.Guid | Should -BeOfType [guid] + $identity.Guid | Should -Be ([guid]'a1111111-1111-1111-1111-111111111111') + $identity.Version | Should -BeOfType [pscustomobject] + $identity.Version.FullVersion | Should -BeExactly '1.2.3-preview.1' + $identity.ManifestPath | Should -BeExactly $manifest + } } diff --git a/tests/private/identity/Read-ManifestDefinition.Tests.ps1 b/tests/private/identity/Read-ManifestDefinition.Tests.ps1 new file mode 100644 index 0000000..2ed0b0b --- /dev/null +++ b/tests/private/identity/Read-ManifestDefinition.Tests.ps1 @@ -0,0 +1,42 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" + . "$PSScriptRoot/../../TestHelpers/Initialize-PMUTestPackage.ps1" +} + +Describe 'Read-ManifestDefinition' -Tag 'PMU' { + It 'returns manifest data without executing the declared root module' { + $path = Initialize-PMUTestPackage $TestDrive '1.2.3-preview.1' + $data = @(Read-ManifestDefinition -ManifestPath (Join-Path $path 'FixtureModule.psd1')) + + $data.Count | Should -Be 1 + $data[0] | Should -BeOfType [hashtable] + $data[0].ModuleVersion | Should -BeExactly '1.2.3' + $data[0].PrivateData.PSData.Prerelease | Should -BeExactly 'preview.1' + } + + It 'reports unsafe data expressions before required-field validation' { + $manifest = Join-Path $TestDrive 'Unsafe.psd1' + Set-Content -LiteralPath $manifest -Value '@{ ModuleVersion = (Get-Date) }' + + { Read-ManifestDefinition -ManifestPath $manifest } | + Should -Throw -ErrorId 'PMU.ModuleIdentityMismatch' -ExpectedMessage 'The module manifest cannot be safely read.' + } + + It 'reports missing identity fields before checking a declared root module' { + $manifest = Join-Path $TestDrive 'MissingIdentity.psd1' + Set-Content -LiteralPath $manifest -Value "@{ ModuleVersion = '1.2.3'; RootModule = '../outside.psm1' }" + + { Read-ManifestDefinition -ManifestPath $manifest } | + Should -Throw -ErrorId 'PMU.ModuleIdentityMismatch' -ExpectedMessage 'The module manifest must declare version and GUID.' + } + + It 'rejects a declared root module outside the package' { + $path = Initialize-PMUTestPackage $TestDrive '1.2.3' + $manifest = Join-Path $path 'FixtureModule.psd1' + $text = (Get-Content -LiteralPath $manifest -Raw).Replace("RootModule = 'FixtureModule.psm1'", "RootModule = '../outside.psm1'") + Set-Content -LiteralPath $manifest -Value $text + + { Read-ManifestDefinition -ManifestPath $manifest } | + Should -Throw -ErrorId 'PMU.ModuleIdentityMismatch' -ExpectedMessage 'The declared root module must remain inside the package directory.' + } +} diff --git a/tests/private/identity/Resolve-ModuleInstallationRoot.Tests.ps1 b/tests/private/identity/Resolve-ModuleInstallationRoot.Tests.ps1 index e895d69..678ab43 100644 --- a/tests/private/identity/Resolve-ModuleInstallationRoot.Tests.ps1 +++ b/tests/private/identity/Resolve-ModuleInstallationRoot.Tests.ps1 @@ -1,9 +1,27 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } -Describe 'Resolve-ModuleInstallationRoot' -Tag 'PMU' { +Describe 'Resolve-ModuleInstallationRoot' -Tag 'PMU', 'PMUCoverageFollowUp' { It 'resolves distinct scope destinations without writing either root' { $user = Resolve-ModuleInstallationRoot 'CurrentUser' $all = Resolve-ModuleInstallationRoot 'AllUsers' $user | Should -Not -Be $all - if (-not $IsWindows) { $all | Should -Be '/usr/local/share/powershell/Modules' } + if (-not $IsWindows) { + $all | Should -Be '/usr/local/share/powershell/Modules' + } + } + It 'resolves Windows AllUsers modules beneath ProgramFiles without creating the root' { + $originalWindows = $IsWindows + $originalProgramFiles = $env:ProgramFiles + try { + Set-Variable IsWindows $true -Force + $env:ProgramFiles = Join-Path $TestDrive 'Program Files' + $expected = Join-Path $env:ProgramFiles 'PowerShell/Modules' + Mock Assert-SafePath { $Path } + Resolve-ModuleInstallationRoot 'AllUsers' | Should -BeExactly $expected + Should -Invoke Assert-SafePath -Times 1 -Exactly -ParameterFilter { $Path -ceq $expected } + Test-Path $env:ProgramFiles | Should -BeFalse + } finally { + Set-Variable IsWindows $originalWindows -Force + $env:ProgramFiles = $originalProgramFiles + } } } diff --git a/tests/private/identity/Test-InstallationDirectoryCandidate.Tests.ps1 b/tests/private/identity/Test-InstallationDirectoryCandidate.Tests.ps1 new file mode 100644 index 0000000..e01a1db --- /dev/null +++ b/tests/private/identity/Test-InstallationDirectoryCandidate.Tests.ps1 @@ -0,0 +1,32 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Test-InstallationDirectoryCandidate' -Tag 'PMU', 'InstalledLayout' { + It 'classifies with manifest as candidate ' -ForEach @( + @{ + name = 'en-US' + manifest = $false + expected = $false + } + @{ + name = 'resources' + manifest = $false + expected = $false + } + @{ + name = '0.1.0' + manifest = $false + expected = $true + } + @{ + name = 'unexpected' + manifest = $true + expected = $true + } + ) { + $root = Join-Path $TestDrive ([guid]::NewGuid()) + $directory = New-Item "$root/$name" -ItemType Directory -Force + if ($manifest) { + $null = New-Item "$directory/FixtureModule.psd1" -ItemType File + } + Test-InstallationDirectoryCandidate $directory 'FixtureModule' | Should -Be $expected + } +} diff --git a/tests/private/installation/Assert-ActivePlanIdentity.Tests.ps1 b/tests/private/installation/Assert-ActivePlanIdentity.Tests.ps1 index af3bf57..1dd2b0e 100644 --- a/tests/private/installation/Assert-ActivePlanIdentity.Tests.ps1 +++ b/tests/private/installation/Assert-ActivePlanIdentity.Tests.ps1 @@ -2,7 +2,10 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Assert-ActivePlanIdentity' -Tag 'PMU' { It 'accepts an unloaded installed reference even when a different copy is loaded' { $operation = Initialize-PMUTestOperation $TestDrive - $script:otherLoaded = [pscustomobject]@{ ManifestPath = "$TestDrive/other/FixtureModule.psd1"; Version = @{ FullVersion = '1.0.0' } } + $script:otherLoaded = [pscustomobject]@{ + ManifestPath = "$TestDrive/other/FixtureModule.psd1" + Version = @{ FullVersion = '1.0.0' } + } $operation.Plan.ActivePath = $otherLoaded.ManifestPath $operation.Plan.ActiveVersion = '1.0.0' Mock Get-ActiveModuleIdentity { $script:otherLoaded } @@ -13,7 +16,10 @@ Describe 'Assert-ActivePlanIdentity' -Tag 'PMU' { $operation.Record.WasLoaded = $true $operation.Plan.ActivePath = $operation.Record.ReferencePath $operation.Plan.ActiveVersion = '2.0.0-preview.1' - Mock Get-ActiveModuleIdentity { [pscustomobject]@{ ManifestPath = 'different.psd1'; Version = @{ FullVersion = '1.0.0' } } } + Mock Get-ActiveModuleIdentity { [pscustomobject]@{ + ManifestPath = 'different.psd1' + Version = @{ FullVersion = '1.0.0' } + } } { Assert-ActivePlanIdentity $operation } | Should -Throw -ErrorId 'PMU.PlanStale' } It 'rejects an active path changed independently from the approved plan' { diff --git a/tests/private/installation/Assert-LoadedPlanIdentity.Tests.ps1 b/tests/private/installation/Assert-LoadedPlanIdentity.Tests.ps1 index b6ab020..4720ec2 100644 --- a/tests/private/installation/Assert-LoadedPlanIdentity.Tests.ps1 +++ b/tests/private/installation/Assert-LoadedPlanIdentity.Tests.ps1 @@ -3,7 +3,10 @@ Describe 'Assert-LoadedPlanIdentity' -Tag 'PMUActualActive' { Context 'Live loaded-state staleness' { It 'rejects a different active path rather than altering the approved snapshot' { $operation = Initialize-PMUTestOperation $TestDrive - Mock Get-ActiveModuleIdentity { [pscustomobject]@{ ManifestPath = '/different/module.psd1'; Version = @{ FullVersion = '9.0.0' } } } + Mock Get-ActiveModuleIdentity { [pscustomobject]@{ + ManifestPath = '/different/module.psd1' + Version = @{ FullVersion = '9.0.0' } + } } { Assert-LoadedPlanIdentity $operation } | Should -Throw -ErrorId 'PMU.PlanStale*' } } diff --git a/tests/private/installation/Assert-NativeHandoffJournal.Tests.ps1 b/tests/private/installation/Assert-NativeHandoffJournal.Tests.ps1 new file mode 100644 index 0000000..ea438dc --- /dev/null +++ b/tests/private/installation/Assert-NativeHandoffJournal.Tests.ps1 @@ -0,0 +1,44 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Assert-NativeHandoffJournal' -Tag 'PMUNative' { + BeforeEach { + $operation = Initialize-PMURecoveryFixture $TestDrive + $operation.Journal.PSObject.Properties.Remove('TransactionId') + $operation.Journal | Add-Member TransactionId ([guid]::NewGuid().ToString('N')) + Write-DurableJournal $operation.Journal + $script:handoffToken = @{ + TransactionId = $operation.Journal.TransactionId + State = $operation.Journal.State + SHA256 = (Get-FileHash $operation.JournalPath).Hash + } + } + It 'accepts the exact transaction and phase only under the unchanged journal token' { + (Assert-NativeHandoffJournal $operation $handoffToken).TransactionId | Should -BeExactly $handoffToken.TransactionId + } + It 'does not roll back or clean up when wins a handoff gap' -ForEach @( + @{ Change = 'Recovery' } + @{ Change = 'Instance' } + @{ Change = 'Legacy' } + @{ Change = 'Phase' } + ) { + switch ($Change) { + Recovery { + Write-RecoveryJournalState $operation.Journal 'RecoveryCompleted' + } + Instance { + $operation.Journal.TransactionId = [guid]::NewGuid().ToString('N') + Write-DurableJournal $operation.Journal + } + Legacy { + $operation.Journal.PSObject.Properties.Remove('TransactionId') + Write-DurableJournal $operation.Journal + } + Phase { + Write-RecoveryJournalState $operation.Journal 'Verification' + } + } + $before = (Get-FileHash $operation.JournalPath).Hash + { Assert-NativeHandoffJournal $operation $handoffToken } | Should -Throw -ErrorId 'PMU.PlanStale' + (Get-FileHash $operation.JournalPath).Hash | Should -BeExactly $before + Test-Path $operation.BackupPath | Should -BeTrue + } +} diff --git a/tests/private/installation/Assert-NativeInstallationSnapshot.Tests.ps1 b/tests/private/installation/Assert-NativeInstallationSnapshot.Tests.ps1 new file mode 100644 index 0000000..c80dcc1 --- /dev/null +++ b/tests/private/installation/Assert-NativeInstallationSnapshot.Tests.ps1 @@ -0,0 +1,44 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Assert-NativeInstallationSnapshot' -Tag 'PMUNativeHandoff' { + BeforeEach { + $operation = Initialize-PMUTestOperation $TestDrive + $path = Split-Path -Parent $operation.Plan.InstalledManifestPath + $script:snapshotSource = [pscustomobject]@{ Name = 'ApprovedFeed'; Uri = $operation.Plan.RepositoryUri } + $script:snapshotContext = [pscustomobject]@{ + Operation = $operation + Snapshot = [pscustomobject]@{ + Path = $path + Version = $operation.Plan.InstalledVersion + Tree = @(Get-FileTreeInventory $path) + Source = $snapshotSource + } + } + Mock Get-NativeInstalledSource { $script:snapshotSource } + } + + It 'accepts the exact observed manifest, tree and source without output' { + @(Assert-NativeInstallationSnapshot $snapshotContext).Count | Should -Be 0 + } + + It 'rejects a changed tree before reading its native source' { + $manifest = $snapshotContext.Operation.Plan.InstalledManifestPath + [IO.File]::AppendAllText($manifest, "`n# changed after observation") + + { Assert-NativeInstallationSnapshot $snapshotContext } | + Should -Throw -ErrorId 'PMU.InstalledStateChanged' + + Should -Invoke Get-NativeInstalledSource -Times 0 -Exactly + } + + It 'rejects a changed native source without accepting the snapshot' { + Mock Get-NativeInstalledSource { + [pscustomobject]@{ Name = 'OtherFeed'; Uri = 'https://other.example.invalid/feed' } + } + + { Assert-NativeInstallationSnapshot $snapshotContext } | + Should -Throw -ErrorId 'PMU.InstalledStateChanged' + } +} diff --git a/tests/private/installation/Assert-NativeParentLease.Tests.ps1 b/tests/private/installation/Assert-NativeParentLease.Tests.ps1 new file mode 100644 index 0000000..df6176f --- /dev/null +++ b/tests/private/installation/Assert-NativeParentLease.Tests.ps1 @@ -0,0 +1,13 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Assert-NativeParentLease' -Tag 'PMU', 'PMUNativeHandoff' { + It 'refuses a disposed or different-file lease rather than treating any lock handle as ownership' { + $operation = Initialize-PMURecoveryFixture $TestDrive + $operation.Lock = Enter-InstallationLock (Join-Path $operation.WorkRoot 'different.lock') + try { + { Assert-NativeParentLease $operation } | Should -Throw -ErrorId 'PMU.PlanStale' + } finally { + $operation.Lock.Dispose() + } + { Assert-NativeParentLease $operation } | Should -Throw -ErrorId 'PMU.PlanStale' + } +} diff --git a/tests/private/installation/Assert-NativeResultOwnership.Tests.ps1 b/tests/private/installation/Assert-NativeResultOwnership.Tests.ps1 new file mode 100644 index 0000000..f148e08 --- /dev/null +++ b/tests/private/installation/Assert-NativeResultOwnership.Tests.ps1 @@ -0,0 +1,59 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Assert-NativeResultOwnership' -Tag 'PMU', 'PMUWorkerContracts' { + BeforeEach { + $script:operation = [pscustomobject]@{} + $script:context = [pscustomobject]@{ + Token = [pscustomobject]@{ TransactionId = 'approved-transaction' } + } + $script:ownedJournal = [pscustomobject]@{ State = 'Verification' } + Mock Assert-NativeHandoffJournal { $script:ownedJournal } + } + + It 'returns the validated journal for the approved transaction' { + $result = [pscustomobject]@{ Token = $context.Token } + $journal = Assert-NativeResultOwnership $operation $context $result + $journal.State | Should -BeExactly 'Verification' + } + + It 'rejects a missing result without reading pending transaction state' { + { Assert-NativeResultOwnership $operation $context $null } | + Should -Throw -ErrorId 'PMU.RecoveryRequired' + Should -Invoke Assert-NativeHandoffJournal -Times 0 + } + + It 'rejects a result from another transaction before reading its journal' { + $result = [pscustomobject]@{ + Token = [pscustomobject]@{ TransactionId = 'different-transaction' } + } + { Assert-NativeResultOwnership $operation $context $result } | + Should -Throw -ErrorId 'PMU.PlanStale' + Should -Invoke Assert-NativeHandoffJournal -Times 0 + } + + It 'validates parent transaction ownership before reporting a tokenless worker error' { + $result = [pscustomobject]@{ + Token = $null + ErrorId = 'PMU.SourceMismatch' + } + { Assert-NativeResultOwnership $operation $context $result } | + Should -Throw -ErrorId 'PMU.SourceMismatch' + Should -Invoke Assert-NativeHandoffJournal -Times 1 -Exactly -ParameterFilter { + $Token.TransactionId -ceq 'approved-transaction' + } + } + + It 'preserves a stale-journal failure instead of replacing it with the worker error' { + Mock Assert-NativeHandoffJournal { + Write-PMUError 'PMU.PlanStale' 'Recovery acquired the transaction.' + } + $result = [pscustomobject]@{ + Token = $null + ErrorId = 'PMU.InstallationFailed' + } + { Assert-NativeResultOwnership $operation $context $result } | + Should -Throw -ErrorId 'PMU.PlanStale' + } +} diff --git a/tests/private/installation/Assert-NativeSelectedInstallation.Tests.ps1 b/tests/private/installation/Assert-NativeSelectedInstallation.Tests.ps1 new file mode 100644 index 0000000..b8a5a9f --- /dev/null +++ b/tests/private/installation/Assert-NativeSelectedInstallation.Tests.ps1 @@ -0,0 +1,98 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" + Import-Module PowerShellGet -RequiredVersion 2.2.5 + function Assert-NativeProviderContext { + } +} +Describe 'Assert-NativeSelectedInstallation' -Tag 'PMU', 'PMUNativeHandoff' { + BeforeEach { + $operation = Initialize-PMUTestOperation $TestDrive + $operation.RepositoryName = 'OriginalNativeName' + $script:selectionRoot = $operation.Plan.DestinationRoot + Mock Resolve-ModuleInstallationRoot { $script:selectionRoot } + $path = Split-Path -Parent $operation.Plan.InstalledManifestPath + [pscustomobject]@{ + Name = $operation.Plan.ModuleName + Repository = $operation.RepositoryName + RepositorySourceLocation = $operation.Plan.RepositoryUri + InstalledLocation = $path + } | Export-Clixml (Join-Path $path 'PSGetModuleInfo.xml') + $script:selectionContext = [pscustomobject]@{ + Operation = $operation + AlreadyInstalled = $false + Snapshot = [pscustomobject]@{ + Path = $path + Version = $operation.Plan.InstalledVersion + Tree = @(Get-FileTreeInventory $path) + Source = [pscustomobject]@{ + Name = $operation.RepositoryName + Uri = $operation.Plan.RepositoryUri + } + } + } + $script:nativeCandidate = [pscustomobject]@{ + InstalledLocation = $path + Repository = $operation.RepositoryName + } + Mock Get-InstalledModule { $script:nativeCandidate } + Mock Assert-NativeProviderContext {} + } + It 'accepts an unchanged selected tree and approved native source in the same scope' { + Assert-NativeSelectedInstallation $selectionContext + } + It 'rejects native selection of a path outside the observed selected-scope installations' { + $nativeCandidate.InstalledLocation = Join-Path $TestDrive 'other-scope' + { Assert-NativeSelectedInstallation $selectionContext } | Should -Throw -ErrorId 'PMU.UnsupportedInstallation' + } + It 'rejects a selected tree changed during the ownership gap' { + [IO.File]::AppendAllText((Join-Path $selectionContext.Snapshot.Path 'FixtureModule.psm1'), 'changed') + { Assert-NativeSelectedInstallation $selectionContext } | Should -Throw -ErrorId 'PMU.InstalledStateChanged' + } + + It 'rejects a snapshot manifest with another before native lookup' -ForEach @( + @{ Property = 'Name'; Value = 'OtherModule'; ErrorId = 'PMU.VerificationFailed' } + @{ Property = 'Guid'; Value = [guid]::Empty; ErrorId = 'PMU.GuidMismatch' } + @{ Property = 'Version'; Value = [pscustomobject]@{ FullVersion = '9.9.9' }; ErrorId = 'PMU.VerificationFailed' } + ) { + $script:snapshotIdentity = Get-ManifestIdentity $selectionContext.Operation.Plan.InstalledManifestPath + $snapshotIdentity.$Property = $Value + Mock Test-InstalledPlanState { $false } + Mock Get-ManifestIdentity { $script:snapshotIdentity } + + { Assert-NativeSelectedInstallation $selectionContext } | Should -Throw -ErrorId $ErrorId + + Should -Invoke Get-InstalledModule -Times 0 -Exactly + } + + It 'rejects changed snapshot source before native lookup' -ForEach @( + @{ Property = 'Name'; Value = 'OtherFeed' } + @{ Property = 'Uri'; Value = 'https://other.example.invalid/feed' } + ) { + $selectionContext.Snapshot.Source.$Property = $Value + + { Assert-NativeSelectedInstallation $selectionContext } | Should -Throw -ErrorId 'PMU.InstalledStateChanged' + + Should -Invoke Get-InstalledModule -Times 0 -Exactly + } + + It 'checks handoff state before reading the snapshot manifest' { + Mock Test-InstalledPlanState { $true } + Mock Get-ManifestIdentity { throw 'Snapshot must not be read.' } + + { Assert-NativeSelectedInstallation $selectionContext } | Should -Throw -ErrorId 'PMU.InstalledStateChanged' + + Should -Invoke Get-ManifestIdentity -Times 0 -Exactly + } + + It 'skips native lookup for an already installed target after validating its snapshot' { + $selectionContext.AlreadyInstalled = $true + Mock Test-InstalledPlanState { $true } + + @(Assert-NativeSelectedInstallation $selectionContext).Count | Should -Be 0 + Should -Invoke Get-InstalledModule -Times 0 -Exactly + + $selectionContext.Snapshot.Source.Name = 'ChangedFeed' + { Assert-NativeSelectedInstallation $selectionContext } | Should -Throw -ErrorId 'PMU.InstalledStateChanged' + Should -Invoke Get-InstalledModule -Times 0 -Exactly + } +} diff --git a/tests/private/installation/Assert-NativeWorkerGo.Tests.ps1 b/tests/private/installation/Assert-NativeWorkerGo.Tests.ps1 new file mode 100644 index 0000000..834aad7 --- /dev/null +++ b/tests/private/installation/Assert-NativeWorkerGo.Tests.ps1 @@ -0,0 +1,23 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Assert-NativeWorkerGo' -Tag 'PMUNative' { + It 'rejects EOF or an invalid GO before acquiring a mutation lease' -ForEach @( + @{ Value = '' } + @{ Value = 'GO extra' } + @{ Value = 'STOP' } + ) { + $reader = [IO.StringReader]::new($Value) + try { + { Assert-NativeWorkerGo $reader } | Should -Throw -ErrorId 'PMU.PlanStale' + } finally { + $reader.Dispose() + } + } + It 'accepts only the explicit pipe GO token' { + $reader = [IO.StringReader]::new("GO`n") + try { + { Assert-NativeWorkerGo $reader } | Should -Not -Throw + } finally { + $reader.Dispose() + } + } +} diff --git a/tests/private/installation/Assert-NativeWorkerParent.Tests.ps1 b/tests/private/installation/Assert-NativeWorkerParent.Tests.ps1 new file mode 100644 index 0000000..cf0613c --- /dev/null +++ b/tests/private/installation/Assert-NativeWorkerParent.Tests.ps1 @@ -0,0 +1,14 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Assert-NativeWorkerParent' -Tag 'PMUNative' { + It 'accepts only the same still-live process instance' { + $context = @{ + ParentPid = $PID + ParentStarted = Get-RepositoryProcessStart $PID + } + { Assert-NativeWorkerParent $context } | Should -Not -Throw + $context.ParentStarted = '0' + { Assert-NativeWorkerParent $context } | Should -Throw -ErrorId 'PMU.PlanStale' + $context.ParentPid = [int]::MaxValue + { Assert-NativeWorkerParent $context } | Should -Throw -ErrorId 'PMU.PlanStale' + } +} diff --git a/tests/private/installation/Assert-NativeWorkerResult.Tests.ps1 b/tests/private/installation/Assert-NativeWorkerResult.Tests.ps1 new file mode 100644 index 0000000..8217743 --- /dev/null +++ b/tests/private/installation/Assert-NativeWorkerResult.Tests.ps1 @@ -0,0 +1,64 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Assert-NativeWorkerResult' -Tag 'PMU', 'PMUNativeHandoff', 'PMUCoverageFollowUp' { + BeforeEach { + $operation = Initialize-PMURecoveryFixture $TestDrive + $script:resultContext = [pscustomobject]@{ Token = Get-NativeJournalToken $operation } + Write-RecoveryJournalState $operation.Journal 'Verification' + $script:workerResult = [pscustomobject]@{ + Success = $true + Token = Get-NativeJournalToken $operation + ErrorId = $null + } + } + It 'requires a reacquired original lock before interpreting the worker result' { + { Assert-NativeWorkerResult $operation $resultContext $workerResult } | Should -Throw -ErrorId 'PMU.PlanStale' + } + It 'accepts an exact successful worker token only while owning the original lock' { + $operation.Lock = Enter-InstallationLock $operation.LockPath + try { + Assert-NativeWorkerResult $operation $resultContext $workerResult + $operation.Journal.State | Should -BeExactly 'Verification' + } finally { + $operation.Lock.Dispose() + } + } + It 'does not perform stale rollback or cleanup if another recovery won the worker-to-parent gap' { + Write-RecoveryJournalState $operation.Journal 'Completed' + $expectedHash = (Get-FileHash $operation.JournalPath).Hash + $operation.Lock = Enter-InstallationLock $operation.LockPath + try { + { Assert-NativeWorkerResult $operation $resultContext $workerResult } | Should -Throw -ErrorId 'PMU.PlanStale' + (Get-FileHash $operation.JournalPath).Hash | Should -BeExactly $expectedHash + Test-Path $operation.BackupPath | Should -BeTrue + } finally { + $operation.Lock.Dispose() + } + } + Context 'Owned worker result failures' { + BeforeEach { + $operation.Lock = Enter-InstallationLock $operation.LockPath + } + AfterEach { + $operation.Lock.Dispose() + } + + It 'propagates an owned worker failure without discarding recoverable evidence' { + $workerResult.Success = $false + $workerResult.ErrorId = 'PMU.InstallationFailed' + $expectedHash = (Get-FileHash $operation.JournalPath).Hash + { Assert-NativeWorkerResult $operation $resultContext $workerResult } | + Should -Throw -ErrorId 'PMU.InstallationFailed' -ExpectedMessage 'The native update failed. Recoverable evidence was preserved.' + (Get-FileHash $operation.JournalPath).Hash | Should -BeExactly $expectedHash + Test-Path $operation.BackupPath | Should -BeTrue + } + + It 'rejects a matching successful token when verification has been superseded' { + Write-RecoveryJournalState $operation.Journal 'RecoveryCompleted' + $workerResult.Token = Get-NativeJournalToken $operation + $expectedHash = (Get-FileHash $operation.JournalPath).Hash + { Assert-NativeWorkerResult $operation $resultContext $workerResult } | + Should -Throw -ErrorId 'PMU.PlanStale' -ExpectedMessage 'The native verification phase was superseded.' + (Get-FileHash $operation.JournalPath).Hash | Should -BeExactly $expectedHash + } + } +} diff --git a/tests/private/installation/Assert-PlanEnvironment.Tests.ps1 b/tests/private/installation/Assert-PlanEnvironment.Tests.ps1 index 9af6db7..ee24da3 100644 --- a/tests/private/installation/Assert-PlanEnvironment.Tests.ps1 +++ b/tests/private/installation/Assert-PlanEnvironment.Tests.ps1 @@ -3,7 +3,12 @@ Describe 'Assert-PlanEnvironment' -Tag 'PMU' { It 'rejects destination drift without attempting a fallback' { $operation = Initialize-PMUTestOperation $TestDrive Mock Get-ProviderContext { @{ Version = [version]'1.2.0' } } - Mock Get-RepositorySnapshot { @{ Name = 'OwnedFeed'; Uri = 'https://example.invalid/feed'; ApiVersion = 'V3'; Trusted = $true } } + Mock Get-RepositorySnapshot { @{ + Name = 'OwnedFeed' + Uri = 'https://example.invalid/feed' + ApiVersion = 'V3' + Trusted = $true + } } Mock Resolve-ModuleInstallationRoot { '/different/root' } { Assert-PlanEnvironment $operation } | Should -Throw -ErrorId 'PMU.PlanStale' } diff --git a/tests/private/installation/Complete-NativeWorkerLease.Tests.ps1 b/tests/private/installation/Complete-NativeWorkerLease.Tests.ps1 new file mode 100644 index 0000000..ac5d0ad --- /dev/null +++ b/tests/private/installation/Complete-NativeWorkerLease.Tests.ps1 @@ -0,0 +1,77 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Complete-NativeWorkerLease' -Tag 'PMU', 'PMUWorkerCleanup' { + BeforeEach { + $script:originalLease = [IO.MemoryStream]::new() + $script:originalLease.Dispose() + $script:replacementLease = [IO.MemoryStream]::new() + $script:operation = @{ + Lock = $script:originalLease + LockPath = Join-Path $TestDrive 'original-parent.lock' + } + $process = [pscustomobject]@{ Disposed = $false } + $process | Add-Member -MemberType ScriptMethod -Name Dispose -Value { + $this.Disposed = $true + } + $script:worker = [pscustomobject]@{ Process = $process } + $script:stopCompleted = $false + Mock Wait-NativeWorkerStop { + [object]::ReferenceEquals($Worker, $script:worker) | Should -BeTrue + $script:stopCompleted = $true + } + Mock Enter-InstallationLock { + $script:stopCompleted | Should -BeTrue + $script:worker.Process.Disposed | Should -BeFalse + $Path | Should -BeExactly $script:operation.LockPath + $script:replacementLease + } + } + + AfterEach { + $script:originalLease.Dispose() + $script:replacementLease.Dispose() + } + + It 'waits for worker stop before reacquiring the original parent lease and disposing the process' { + Complete-NativeWorkerLease $operation $worker + + [object]::ReferenceEquals($operation.Lock, $replacementLease) | Should -BeTrue + $operation.Lock.CanRead | Should -BeTrue + $worker.Process.Disposed | Should -BeTrue + Should -Invoke Enter-InstallationLock -Times 1 -Exactly + } + + It 'reacquires the original parent lease and disposes the process when worker stop fails' { + $script:stopException = [InvalidOperationException]::new('Worker stop fixture failure') + Mock Wait-NativeWorkerStop { + $script:stopCompleted = $true + throw [Management.Automation.ErrorRecord]::new( + $script:stopException, 'PMU.WorkerStopFailed', 'InvalidOperation', $null + ) + } + + $failure = { Complete-NativeWorkerLease $operation $worker } | + Should -Throw -ErrorId 'PMU.WorkerStopFailed' -PassThru + + [object]::ReferenceEquals($failure.Exception, $stopException) | Should -BeTrue + [object]::ReferenceEquals($operation.Lock, $replacementLease) | Should -BeTrue + $operation.Lock.CanRead | Should -BeTrue + $worker.Process.Disposed | Should -BeTrue + } + + It 'disposes the process and leaves no claimed parent lease when reacquisition fails' { + Mock Enter-InstallationLock { + $script:stopCompleted | Should -BeTrue + $Path | Should -BeExactly $script:operation.LockPath + Write-PMUError 'PMU.LockTimeout' 'Parent lease fixture failure' + } + + { Complete-NativeWorkerLease $operation $worker } | + Should -Throw -ErrorId 'PMU.LockTimeout' + + $operation.Lock | Should -BeNullOrEmpty + $worker.Process.Disposed | Should -BeTrue + } +} diff --git a/tests/private/installation/Complete-VerifiedInstallation.Tests.ps1 b/tests/private/installation/Complete-VerifiedInstallation.Tests.ps1 index 921a15b..c07c03f 100644 --- a/tests/private/installation/Complete-VerifiedInstallation.Tests.ps1 +++ b/tests/private/installation/Complete-VerifiedInstallation.Tests.ps1 @@ -21,4 +21,18 @@ Describe 'Complete-VerifiedInstallation' -Tag 'PMU' { Should -Invoke Initialize-Replacement -Times 0 Should -Invoke Invoke-ProviderInstallation -Times 0 } + It 'finalizes independently verified worker output without running another provider writer' -Tag 'PMUNativeHandoff' { + $operation = Initialize-PMURecoveryFixture $TestDrive + $script:verifiedRoot = $operation.Plan.DestinationRoot + Mock Resolve-ModuleInstallationRoot { $script:verifiedRoot } + Invoke-PMUFixtureInstallation $operation + Write-RecoveryJournalState $operation.Journal 'Verification' + Mock Initialize-Replacement { throw 'The worker transaction already exists.' } + Mock Invoke-ProviderInstallation { throw 'The parent must not install again.' } + $tree = @($operation.Journal.StagedTree) + $result = Complete-VerifiedInstallation $operation $tree $false + $result.Outcome | Should -BeExactly 'Installed' + (Read-RecoveryJournal $operation).State | Should -BeExactly 'Completed' + Should -Invoke Invoke-ProviderInstallation -Times 0 + } } diff --git a/tests/private/installation/Confirm-NativeExistingTarget.Tests.ps1 b/tests/private/installation/Confirm-NativeExistingTarget.Tests.ps1 new file mode 100644 index 0000000..8a01e6e --- /dev/null +++ b/tests/private/installation/Confirm-NativeExistingTarget.Tests.ps1 @@ -0,0 +1,33 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Confirm-NativeExistingTarget' -Tag 'PMU', 'PMUWorkerContracts' { + BeforeEach { + $script:operation = Initialize-PMURecoveryFixture $TestDrive + $script:tree = @([pscustomobject]@{ + Path = 'approved-target' + SHA256 = 'approved-hash' + }) + Mock Assert-InstalledPackage {} + } + + It 'persists verification state only after validating the existing target' { + Confirm-NativeExistingTarget $operation $tree + $saved = Get-Content -LiteralPath $operation.JournalPath -Raw | ConvertFrom-Json + $saved.State | Should -BeExactly 'Verification' + Should -Invoke Assert-InstalledPackage -Times 1 -Exactly -ParameterFilter { + $Tree[0].SHA256 -ceq 'approved-hash' + } + } + + It 'leaves the pending journal unchanged when installed-tree verification fails' { + $before = (Get-FileHash -LiteralPath $operation.JournalPath).Hash + Mock Assert-InstalledPackage { + Write-PMUError 'PMU.VerificationFailed' 'The installed tree changed.' + } + { Confirm-NativeExistingTarget $operation $tree } | + Should -Throw -ErrorId 'PMU.VerificationFailed' + (Get-FileHash -LiteralPath $operation.JournalPath).Hash | Should -BeExactly $before + } +} diff --git a/tests/private/installation/ConvertTo-InstallationOperation.Tests.ps1 b/tests/private/installation/ConvertTo-InstallationOperation.Tests.ps1 index f4633c2..293f466 100644 --- a/tests/private/installation/ConvertTo-InstallationOperation.Tests.ps1 +++ b/tests/private/installation/ConvertTo-InstallationOperation.Tests.ps1 @@ -1,8 +1,42 @@ -BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} Describe 'ConvertTo-InstallationOperation' -Tag 'PMU' { It 'groups in-memory inputs without writing any persistent state' { $operation = Initialize-PMUTestOperation $TestDrive $operation.LockPath | Should -Be (Join-Path $operation.WorkRoot 'operation.lock') Test-Path $operation.WorkRoot | Should -BeFalse } + + It 'retains input references and the original flat property order' { + $plan = [pscustomobject]@{ + Scope = 'CurrentUser' + ModuleName = 'FixtureModule' + TargetNumericVersion = '2.0.0' + } + $record = [pscustomobject]@{ + InstalledTree = @('original') + } + $consent = @{ + AcceptLicense = $true + } + $credential = [pscredential]::new('fixture', [securestring]::new()) + Mock Resolve-PMUDataRoot { Join-Path $TestDrive 'data' } + Mock Get-InstallationLockIdentity { 'fixture-lock' } + + $operation = ConvertTo-InstallationOperation $plan $record $consent $credential + + $operation.GetType() | Should -Be ([System.Management.Automation.PSCustomObject]) + [object]::ReferenceEquals($operation.Plan, $plan) | Should -BeTrue + [object]::ReferenceEquals($operation.Record, $record) | Should -BeTrue + [object]::ReferenceEquals($operation.Consent, $consent) | Should -BeTrue + [object]::ReferenceEquals($operation.Credential, $credential) | Should -BeTrue + $operation.RepositoryName | Should -BeNullOrEmpty + $expectedNames = @( + 'Plan', 'Record', 'Consent', 'Credential', 'RepositoryName' + 'DataRoot', 'WorkRoot', 'LockPath', 'JournalPath', 'StageRoot', 'BackupPath' + 'Reinstall', 'Journal', 'Lock', 'StagedPath', 'RecoveryStatus', 'RequiresLicenseAcceptance' + ) + ($operation.PSObject.Properties.Name -join ',') | Should -BeExactly ($expectedNames -join ',') + } } diff --git a/tests/private/installation/ConvertTo-InstallationWorkspace.Tests.ps1 b/tests/private/installation/ConvertTo-InstallationWorkspace.Tests.ps1 new file mode 100644 index 0000000..2966123 --- /dev/null +++ b/tests/private/installation/ConvertTo-InstallationWorkspace.Tests.ps1 @@ -0,0 +1,49 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'ConvertTo-InstallationWorkspace' -Tag 'PMU' { + BeforeEach { + $plan = [pscustomobject]@{ + ModuleName = 'FixtureModule' + TargetNumericVersion = '2.0.0' + } + $dataRoot = Join-Path $TestDrive 'data' + $workRoot = Join-Path $dataRoot 'fixture-lock' + $script:workspace = ConvertTo-InstallationWorkspace $plan $dataRoot $workRoot + } + + It 'projects prepared paths in lifecycle order without creating the workspace' { + $workspace.GetType() | Should -Be ([Collections.Specialized.OrderedDictionary]) + $expectedNames = @( + 'DataRoot', 'WorkRoot', 'LockPath', 'JournalPath', 'StageRoot', 'BackupPath' + 'Reinstall', 'Journal', 'Lock', 'StagedPath', 'RecoveryStatus', 'RequiresLicenseAcceptance' + ) + ($workspace.Keys -join ',') | Should -BeExactly ($expectedNames -join ',') + $workspace.DataRoot | Should -BeExactly $dataRoot + $workspace.WorkRoot | Should -BeExactly $workRoot + $workspace.LockPath | Should -BeExactly (Join-Path $workRoot 'operation.lock') + $workspace.JournalPath | Should -BeExactly (Join-Path $workRoot 'journal.json') + $workspace.StageRoot | Should -BeExactly (Join-Path $workRoot 'stage') + $workspace.BackupPath | Should -BeExactly (Join-Path $workRoot 'backup') + $workspace.StagedPath | Should -BeExactly (Join-Path $workRoot 'stage/FixtureModule/2.0.0') + Test-Path $workRoot | Should -BeFalse + } + + It 'starts each workspace with independent lifecycle state' { + $workspace.Reinstall = $true + $workspace.Journal = @{ + State = 'Prepared' + } + $workspace.Lock = [object]::new() + $fresh = ConvertTo-InstallationWorkspace $plan $dataRoot $workRoot + + $fresh.Reinstall | Should -BeFalse + $fresh.Reinstall | Should -BeOfType ([bool]) + ($null -eq $fresh.Journal) | Should -BeTrue + ($null -eq $fresh.Lock) | Should -BeTrue + $fresh.RecoveryStatus | Should -BeExactly 'NotRequired' + $fresh.RequiresLicenseAcceptance | Should -BeFalse + $fresh.RequiresLicenseAcceptance | Should -BeOfType ([bool]) + } +} diff --git a/tests/private/installation/ConvertTo-NativeWorkerContext.Tests.ps1 b/tests/private/installation/ConvertTo-NativeWorkerContext.Tests.ps1 new file mode 100644 index 0000000..d52d91d --- /dev/null +++ b/tests/private/installation/ConvertTo-NativeWorkerContext.Tests.ps1 @@ -0,0 +1,58 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'ConvertTo-NativeWorkerContext' -Tag 'PMU', 'PMUWorkerContracts' { + BeforeEach { + $script:operation = Initialize-PMURecoveryFixture $TestDrive + $script:snapshot = [pscustomobject]@{ + Source = [pscustomobject]@{ Name = 'ApprovedFeed' } + } + $script:modulePath = Join-Path $TestDrive 'PSModuleUpdate.psm1' + $script:ownedRoot = Join-Path $TestDrive 'native-provider' + $script:moduleRoot = Join-Path $TestDrive 'Modules' + Mock Get-Module { + [pscustomobject]@{ Path = $script:modulePath } + } -ParameterFilter { $Name -eq 'PSModuleUpdate' } + Mock Get-NativeProviderRoot { $script:ownedRoot } + Mock Resolve-ModuleInstallationRoot { $script:moduleRoot } + Mock Get-RepositoryProcessStart { 'parent-start-identity' } + } + + It 'copies the approved plan and provider roots without transferring the parent lock' { + $context = ConvertTo-NativeWorkerContext $operation $snapshot $false + $context.ModulePath | Should -BeExactly $script:modulePath + $context.ParentPid | Should -Be $PID + $context.ParentStarted | Should -BeExactly 'parent-start-identity' + $context.AlreadyInstalled | Should -BeFalse + $context.Operation.RepositoryName | Should -BeExactly 'ApprovedFeed' + $context.Operation.NativeContext.Root | Should -BeExactly $script:ownedRoot + $context.Operation.NativeContext.CurrentUserRoot | Should -BeExactly $script:moduleRoot + $context.Operation.Plan.ModuleName | Should -BeExactly 'FixtureModule' + $context.Operation.Plan.TargetVersion | Should -BeExactly '2.0.0-preview.2' + $context.Operation.PSObject.Properties.Name | Should -Not -Contain 'Lock' + $context.Operation.Credential | Should -Be $operation.Credential + } + + It 'preserves existing-target mode and transaction identity for the worker' { + $context = ConvertTo-NativeWorkerContext $operation $snapshot $true + $context.AlreadyInstalled | Should -BeTrue + $context.Token.TransactionId | Should -BeExactly $operation.Journal.TransactionId + $context.Snapshot.Source.Name | Should -BeExactly 'ApprovedFeed' + } + + It 'rejects an unavailable built PMU module' { + Mock Get-Module {} -ParameterFilter { $Name -eq 'PSModuleUpdate' } + { ConvertTo-NativeWorkerContext $operation $snapshot $false } | + Should -Throw -ErrorId 'PMU.UnsupportedProviderBehavior' + } + + It 'rejects ambiguous built PMU module instances' { + Mock Get-Module { + [pscustomobject]@{ Path = $script:modulePath } + [pscustomobject]@{ Path = $script:modulePath } + } -ParameterFilter { $Name -eq 'PSModuleUpdate' } + { ConvertTo-NativeWorkerContext $operation $snapshot $false } | + Should -Throw -ErrorId 'PMU.UnsupportedProviderBehavior' + } +} diff --git a/tests/private/installation/ConvertTo-NativeWorkerOperation.Tests.ps1 b/tests/private/installation/ConvertTo-NativeWorkerOperation.Tests.ps1 new file mode 100644 index 0000000..c8f15c8 --- /dev/null +++ b/tests/private/installation/ConvertTo-NativeWorkerOperation.Tests.ps1 @@ -0,0 +1,27 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'ConvertTo-NativeWorkerOperation' -Tag 'PMU', 'PMUWorkerContracts' { + It 'transfers credential identity and inventory without copying the lock or mutating the parent' { + $operation = Initialize-PMURecoveryFixture $TestDrive + $credential = [pscredential]::new('fixture-user', [securestring]::new()) + $operation.Credential = $credential + $snapshot = [pscustomobject]@{ Source = [pscustomobject]@{ Name = 'ApprovedFeed' } } + Mock Get-NativeProviderRoot { 'owned-root' } + Mock Resolve-ModuleInstallationRoot { 'module-root' } + + $copy = ConvertTo-NativeWorkerOperation $operation $snapshot + + [object]::ReferenceEquals($copy.Credential, $credential) | Should -BeTrue + [object]::ReferenceEquals($copy.Plan, $operation.Plan) | Should -BeTrue + [object]::ReferenceEquals($copy.Record, $operation.Record) | Should -BeFalse + @($copy.Record.InstalledTree) | Should -Be @($operation.Record.InstalledTree) + $copy.PSObject.Properties.Name | Should -Not -Contain 'Lock' + $operation.PSObject.Properties.Name | Should -Not -Contain 'NativeContext' + $copy.RepositoryName | Should -BeExactly 'ApprovedFeed' + $copy.NativeContext.Root | Should -BeExactly 'owned-root' + $copy.NativeContext.CurrentUserRoot | Should -BeExactly 'module-root' + $copy.NativeContext.Paths | Should -BeNullOrEmpty + } +} diff --git a/tests/private/installation/ConvertTo-NativeWorkerResult.Tests.ps1 b/tests/private/installation/ConvertTo-NativeWorkerResult.Tests.ps1 new file mode 100644 index 0000000..7629a6d --- /dev/null +++ b/tests/private/installation/ConvertTo-NativeWorkerResult.Tests.ps1 @@ -0,0 +1,20 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'ConvertTo-NativeWorkerResult' -Tag 'PMU', 'PMUNativeHandoff' { + It 'binds a successful reply to the exact durable journal while withholding credentials' { + $operation = Initialize-PMURecoveryFixture $TestDrive + $result = ConvertTo-NativeWorkerResult $operation $null + $result.Success | Should -BeTrue + $result.Token.SHA256 | Should -BeExactly (Get-FileHash $operation.JournalPath).Hash + $result.PSObject.Properties.Name | Should -Not -Contain 'Credential' + } + It 'never gives pre-ownership failures a journal token or raw provider diagnostics' { + $failure = [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new('fixture-only-sensitive-diagnostic'), 'ProviderFailure', 'InvalidOperation', $null + ) + $result = ConvertTo-NativeWorkerResult $null $failure + $result.Success | Should -BeFalse + $result.Token | Should -BeNullOrEmpty + $result.ErrorId | Should -BeExactly 'PMU.InstallationFailed' + $result | ConvertTo-Json | Should -Not -Match 'fixture-only-sensitive-diagnostic' + } +} diff --git a/tests/private/installation/ConvertTo-UpdateResult.Tests.ps1 b/tests/private/installation/ConvertTo-UpdateResult.Tests.ps1 index 7dfbbd1..f2c3602 100644 --- a/tests/private/installation/ConvertTo-UpdateResult.Tests.ps1 +++ b/tests/private/installation/ConvertTo-UpdateResult.Tests.ps1 @@ -9,4 +9,18 @@ Describe 'ConvertTo-UpdateResult' -Tag 'PMU' { $result.InstalledVersion | Should -Be '2.0.0-preview.1' $result.TargetVersion | Should -Be '2.0.0-preview.2' } + It 'preserves ordered typed output without copying private plan context' { + $operation = Initialize-PMUTestOperation $TestDrive + $operation.Plan | Add-Member Credential 'PMUPrivacySentinel-85d3b6d1-Credential' + $operation.Plan | Add-Member Parent 'PMUPrivacySentinel-85d3b6d1-Parent' + $operation.Plan | Add-Member Context 'PMUPrivacySentinel-85d3b6d1-Context' + $result = ConvertTo-UpdateResult $operation.Plan 'WhatIf' + $fields = 'SchemaVersion Outcome ModuleName ModuleGuid RepositoryName RepositoryUri Scope ActiveVersion InstalledVersion TargetVersion InstalledPath VerificationStatus RestartRequired DependencySummary RecoveryStatus RecoveryGuidance'.Split(' ') + @($result.PSObject.Properties.Name) | Should -Be $fields + $result.PSTypeNames[0] | Should -BeExactly 'PSModuleUpdate.UpdateResult' + $result.ModuleGuid | Should -BeOfType ([guid]) + $result.RestartRequired | Should -BeOfType ([bool]) + ($result | ConvertTo-Json -Depth 8) | Should -Not -Match 'PMUPrivacySentinel-85d3b6d1-' + ($result | ConvertTo-Json -Depth 8) | Should -Not -Match '"(?:Credential|Parent|Context)"\s*:' + } } diff --git a/tests/private/installation/ConvertTo-UpdateResultIdentity.Tests.ps1 b/tests/private/installation/ConvertTo-UpdateResultIdentity.Tests.ps1 new file mode 100644 index 0000000..77b253f --- /dev/null +++ b/tests/private/installation/ConvertTo-UpdateResultIdentity.Tests.ps1 @@ -0,0 +1,15 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'ConvertTo-UpdateResultIdentity' -Tag 'PMU' { + It 'allow-lists repository and installation identity while preserving GUID and null active version' { + $operation = Initialize-PMUTestOperation $TestDrive + $operation.Plan | Add-Member Credential 'secret' + $operation.Plan | Add-Member Parent 'private' + $operation.Plan | Add-Member Context 'private' + $identity = ConvertTo-UpdateResultIdentity $operation.Plan + @($identity.Keys) | Should -Be 'ModuleName ModuleGuid RepositoryName RepositoryUri Scope ActiveVersion'.Split(' ') + $identity.ModuleGuid | Should -BeOfType ([guid]) + $identity.RepositoryUri | Should -Be $operation.Plan.RepositoryUri + $identity.ActiveVersion | Should -BeNullOrEmpty + ($identity | ConvertTo-Json) | Should -Not -Match 'Credential|Parent|Context|secret|private' + } +} diff --git a/tests/private/installation/ConvertTo-UpdateResultOutcome.Tests.ps1 b/tests/private/installation/ConvertTo-UpdateResultOutcome.Tests.ps1 new file mode 100644 index 0000000..bcdd29e --- /dev/null +++ b/tests/private/installation/ConvertTo-UpdateResultOutcome.Tests.ps1 @@ -0,0 +1,34 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'ConvertTo-UpdateResultOutcome' -Tag 'PMU' { + It 'reports verified installation facts and restart policy in outcome order' { + $operation = Initialize-PMUTestOperation $TestDrive + $installed = @{ + Version = '2.0.0-preview.2' + Path = 'verified-path' + } + Mock Test-RestartRequired { $true } + $outcome = ConvertTo-UpdateResultOutcome $operation.Plan $installed 'Installed' 'Verified' + @($outcome.Keys) | Should -Be 'InstalledVersion TargetVersion InstalledPath VerificationStatus RestartRequired DependencySummary RecoveryStatus RecoveryGuidance'.Split(' ') + $outcome.InstalledVersion | Should -BeExactly $installed.Version + $outcome.InstalledPath | Should -BeExactly $installed.Path + $outcome.RestartRequired | Should -BeTrue + Should -Invoke Test-RestartRequired -Times 1 -Exactly -ParameterFilter { $Outcome -eq 'Installed' } + } + It 'copies dependency containers but not items and excludes private installation context' { + $operation = Initialize-PMUTestOperation $TestDrive + $dependency = [pscustomobject]@{ Name = 'Dependency' } + $operation.Plan.DependencySummary = @($dependency) + $installed = @{ + Version = $null + Path = $null + Credential = 'secret' + Parent = 'private' + Context = 'private' + } + Mock Test-RestartRequired { $false } + $outcome = ConvertTo-UpdateResultOutcome $operation.Plan $installed 'WhatIf' 'NotPerformed' + [object]::ReferenceEquals($outcome.DependencySummary, $operation.Plan.DependencySummary) | Should -BeFalse + [object]::ReferenceEquals($outcome.DependencySummary[0], $dependency) | Should -BeTrue + ($outcome | ConvertTo-Json -Depth 8) | Should -Not -Match 'Credential|Parent|Context|secret|private' + } +} diff --git a/tests/private/installation/Get-NativeJournalToken.Tests.ps1 b/tests/private/installation/Get-NativeJournalToken.Tests.ps1 new file mode 100644 index 0000000..380066b --- /dev/null +++ b/tests/private/installation/Get-NativeJournalToken.Tests.ps1 @@ -0,0 +1,10 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Get-NativeJournalToken' -Tag 'PMUNative' { + It 'binds exact bytes and phase to the private transaction instance' { + $operation = Initialize-PMURecoveryFixture $TestDrive + $token = Get-NativeJournalToken $operation + $token.TransactionId | Should -BeExactly $operation.Journal.TransactionId + $token.SHA256 | Should -BeExactly (Get-FileHash $operation.JournalPath).Hash + $token.State | Should -BeExactly 'ReplacementInProgress' + } +} diff --git a/tests/private/installation/Get-NativeSelectedSnapshot.Tests.ps1 b/tests/private/installation/Get-NativeSelectedSnapshot.Tests.ps1 new file mode 100644 index 0000000..fe7130b --- /dev/null +++ b/tests/private/installation/Get-NativeSelectedSnapshot.Tests.ps1 @@ -0,0 +1,49 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Get-NativeSelectedSnapshot' -Tag 'PMU', 'PMUWorkerContracts' { + BeforeEach { + $operation = Initialize-PMUTestOperation $TestDrive + $operation.Plan.TargetInstalledPath = Initialize-PMUTestPackage $operation.Plan.DestinationRoot '3.0.0' + $script:approvedSource = [pscustomobject]@{ + Name = 'ApprovedFeed' + Uri = 'https://feed.invalid' + } + Mock Get-NativeInstalledSource { $script:approvedSource } + } + + It 'snapshots the selected installation for AlreadyInstalled=' -ForEach @( + @{ + AlreadyInstalled = $false + Version = '2.0.0-preview.1' + } + @{ + AlreadyInstalled = $true + Version = '3.0.0' + } + ) { + $expected = Split-Path -Parent $operation.Plan.InstalledManifestPath + if ($AlreadyInstalled) { + $expected = $operation.Plan.TargetInstalledPath + } + $snapshot = Get-NativeSelectedSnapshot $operation $AlreadyInstalled + $snapshot.Path | Should -BeExactly $expected + $snapshot.Version | Should -BeExactly $Version + $snapshot.Source.Uri | Should -BeExactly 'https://feed.invalid' + $snapshot.Tree.Count | Should -BeGreaterThan 0 + Should -Invoke Get-NativeInstalledSource -Times 1 -Exactly -ParameterFilter { + $SelectedPath -ceq $expected + } + } + + It 'refuses to snapshot files after native source validation fails' { + Mock Get-NativeInstalledSource { + Write-PMUError 'PMU.SourceMismatch' 'The source changed.' + } + Mock Get-FileTreeInventory {} + { Get-NativeSelectedSnapshot $operation $false } | + Should -Throw -ErrorId 'PMU.SourceMismatch' + Should -Invoke Get-FileTreeInventory -Times 0 + } +} diff --git a/tests/private/installation/Invoke-LockedInstallation.Tests.ps1 b/tests/private/installation/Invoke-LockedInstallation.Tests.ps1 index a018189..689aa17 100644 --- a/tests/private/installation/Invoke-LockedInstallation.Tests.ps1 +++ b/tests/private/installation/Invoke-LockedInstallation.Tests.ps1 @@ -1,5 +1,9 @@ # The real public workflow owns full staging/rollback coverage; this test isolates lock-held revalidation. -BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" + function Invoke-NativeHandoff { + } +} Describe 'Invoke-LockedInstallation' -Tag 'PMU' { It 'rechecks environment after lock acquisition before staging or recovery changes' { $operation = Initialize-PMUTestOperation $TestDrive @@ -8,4 +12,19 @@ Describe 'Invoke-LockedInstallation' -Tag 'PMU' { { Invoke-LockedInstallation $operation } | Should -Throw -ErrorId 'PMU.RepositoryChanged' Should -Invoke Initialize-StagedPackage -Times 0 } + It 'delegates staging and native mutation to the lock-handoff worker rather than a parent writer' -Tag 'PMUNativeHandoff' { + $operation = Initialize-PMUTestOperation $TestDrive + Mock Assert-PlanEnvironment {} + Mock Repair-UnfinishedInstallation {} + Mock Test-InstalledPlanState { $false } + Mock Clear-CompletedOperation {} + Mock Assert-ExactTarget {} + Mock Assert-InstallationConsent {} + Mock Initialize-StagedPackage { throw 'The parent must not perform provider staging.' } + Mock Invoke-NativeHandoff { @() } + Mock Complete-VerifiedInstallation { [pscustomobject]@{ Outcome = 'Installed' } } + (Invoke-LockedInstallation $operation).Outcome | Should -BeExactly 'Installed' + Should -Invoke Invoke-NativeHandoff -Times 1 + Should -Invoke Initialize-StagedPackage -Times 0 + } } diff --git a/tests/private/installation/Invoke-NativeHandoff.Tests.ps1 b/tests/private/installation/Invoke-NativeHandoff.Tests.ps1 new file mode 100644 index 0000000..7f792aa --- /dev/null +++ b/tests/private/installation/Invoke-NativeHandoff.Tests.ps1 @@ -0,0 +1,56 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Invoke-NativeHandoff' -Tag 'PMU', 'PMUWorkerProtocol' { + BeforeEach { + $script:operation = Initialize-PMURecoveryFixture $TestDrive + $script:snapshot = [pscustomobject]@{ Marker = 'selected-installation' } + Mock Get-NativeSelectedSnapshot { $script:snapshot } + Mock Initialize-Replacement {} + Mock ConvertTo-NativeWorkerContext { [pscustomobject]@{ Marker = 'approved-worker' } } + Mock Invoke-NativeWorkerProcess { [pscustomobject]@{ Success = $true } } + Mock Assert-NativeWorkerResult {} + } + + AfterEach { + if ($operation.Lock) { + $operation.Lock.Dispose() + } + } + + It 'does not start a worker without ownership of the original lock' { + { Invoke-NativeHandoff $operation $false } | Should -Throw -ErrorId 'PMU.PlanStale' + Should -Invoke Invoke-NativeWorkerProcess -Times 0 + } + + It 'rejects a plan without a newer target before starting a worker' { + $operation.Lock = Enter-InstallationLock $operation.LockPath + $operation.Plan.UpdateAvailable = $false + { Invoke-NativeHandoff $operation $false } | Should -Throw -ErrorId 'PMU.PlanStale' + Should -Invoke Invoke-NativeWorkerProcess -Times 0 + } + + It 'returns verified staged-tree evidence after an authorized handoff' { + $operation.Lock = Enter-InstallationLock $operation.LockPath + $expected = $operation.Journal.StagedTree | ConvertTo-Json -Depth 6 + $actual = Invoke-NativeHandoff $operation $false | ConvertTo-Json -Depth 6 + $actual | Should -BeExactly $expected + Should -Invoke Assert-NativeWorkerResult -Times 1 -Exactly + } + + It 'permits existing-target verification without authorizing a newer installation' { + $operation.Lock = Enter-InstallationLock $operation.LockPath + $operation.Plan.UpdateAvailable = $false + Invoke-NativeHandoff $operation $true | Should -Not -BeNullOrEmpty + Should -Invoke Get-NativeSelectedSnapshot -Times 1 -Exactly -ParameterFilter { $AlreadyInstalled } + } + + It 'propagates rejected worker evidence instead of returning the staged tree' { + $operation.Lock = Enter-InstallationLock $operation.LockPath + Mock Assert-NativeWorkerResult { + Write-PMUError 'PMU.RecoveryRequired' 'The worker result is not owned.' + } + { Invoke-NativeHandoff $operation $false } | Should -Throw -ErrorId 'PMU.RecoveryRequired' + } +} diff --git a/tests/private/installation/Invoke-NativeWorker.Tests.ps1 b/tests/private/installation/Invoke-NativeWorker.Tests.ps1 new file mode 100644 index 0000000..9955379 --- /dev/null +++ b/tests/private/installation/Invoke-NativeWorker.Tests.ps1 @@ -0,0 +1,49 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Invoke-NativeWorker' -Tag 'PMU', 'PMUWorkerProtocol' { + BeforeEach { + $script:context = [pscustomobject]@{ Operation = [pscustomobject]@{} } + $script:messages = [Collections.Generic.List[object]]::new() + $script:leaseEntered = $false + Mock Initialize-NativeProvider {} + Mock Assert-NativeWorkerGo {} + Mock Invoke-NativeWorkerLease { $script:leaseEntered = $true } + Mock Write-NativeWorkerMessage { $script:messages.Add($Message) } + } + + It 'announces READY after provider setup and waits for permission before entering the lease' { + Mock Assert-NativeWorkerGo { + $script:messages.Count | Should -Be 1 + $script:messages[0].Phase | Should -BeExactly 'READY' + $script:leaseEntered | Should -BeFalse + } + Invoke-NativeWorker $context + $leaseEntered | Should -BeTrue + } + + It 'reports provider setup failure without announcing READY or entering a lease' { + Mock Initialize-NativeProvider { + Write-PMUError 'PMU.SourceMismatch' 'The source changed.' + } + Invoke-NativeWorker $context + $messages.Count | Should -Be 1 + $messages[0].Phase | Should -BeExactly 'RESULT' + $messages[0].Success | Should -BeFalse + $messages[0].ErrorId | Should -BeExactly 'PMU.SourceMismatch' + $leaseEntered | Should -BeFalse + } + + It 'reports rejected permission after READY without entering a lease' { + Mock Assert-NativeWorkerGo { + Write-PMUError 'PMU.PlanStale' 'The parent cancelled.' + } + Invoke-NativeWorker $context + $messages.Count | Should -Be 2 + $messages[0].Phase | Should -BeExactly 'READY' + $messages[1].Success | Should -BeFalse + $messages[1].ErrorId | Should -BeExactly 'PMU.PlanStale' + $leaseEntered | Should -BeFalse + } +} diff --git a/tests/private/installation/Invoke-NativeWorkerExchange.Tests.ps1 b/tests/private/installation/Invoke-NativeWorkerExchange.Tests.ps1 new file mode 100644 index 0000000..855472e --- /dev/null +++ b/tests/private/installation/Invoke-NativeWorkerExchange.Tests.ps1 @@ -0,0 +1,66 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Invoke-NativeWorkerExchange' -Tag 'PMU', 'PMUWorkerProtocol' { + BeforeEach { + $script:operation = [pscustomobject]@{} + $script:goSent = $false + Mock Send-NativeWorkerGo { $script:goSent = $true } + } + + It 'returns an early worker failure without granting mutation permission' { + $reader = [IO.StringReader]::new('PMU_NATIVE:{"Phase":"RESULT","Success":false}') + $worker = [pscustomobject]@{ Process = [pscustomobject]@{ StandardOutput = $reader } } + try { + $result = Invoke-NativeWorkerExchange $operation $worker + $result.Success | Should -BeFalse + $goSent | Should -BeFalse + } finally { + $reader.Dispose() + } + } + + It 'grants mutation only after READY and returns the subsequent result' { + $protocol = @' +PMU_NATIVE:{"Phase":"READY"} +PMU_NATIVE:{"Phase":"RESULT","Success":true} +'@ + $reader = [IO.StringReader]::new($protocol) + $worker = [pscustomobject]@{ Process = [pscustomobject]@{ StandardOutput = $reader } } + try { + $result = Invoke-NativeWorkerExchange $operation $worker + $goSent | Should -BeTrue + $result.Phase | Should -BeExactly 'RESULT' + $result.Success | Should -BeTrue + } finally { + $reader.Dispose() + } + } + + It 'rejects an unexpected ready phase without granting mutation: ' -ForEach @( + @{ Phase = 'ready' } + @{ Phase = 'GO' } + @{ Phase = 'UNKNOWN' } + ) { + $reader = [IO.StringReader]::new("PMU_NATIVE:{`"Phase`":`"$Phase`"}") + $worker = [pscustomobject]@{ Process = [pscustomobject]@{ StandardOutput = $reader } } + try { + { Invoke-NativeWorkerExchange $operation $worker } | Should -Throw -ErrorId 'PMU.PlanStale' + $goSent | Should -BeFalse + } finally { + $reader.Dispose() + } + } + + It 'reports a worker that stops after READY instead of returning a successful result' { + $reader = [IO.StringReader]::new('PMU_NATIVE:{"Phase":"READY"}') + $worker = [pscustomobject]@{ Process = [pscustomobject]@{ StandardOutput = $reader } } + try { + { Invoke-NativeWorkerExchange $operation $worker } | Should -Throw -ErrorId 'PMU.RecoveryRequired' + $goSent | Should -BeTrue + } finally { + $reader.Dispose() + } + } +} diff --git a/tests/private/installation/Invoke-NativeWorkerLease.Tests.ps1 b/tests/private/installation/Invoke-NativeWorkerLease.Tests.ps1 new file mode 100644 index 0000000..2995974 --- /dev/null +++ b/tests/private/installation/Invoke-NativeWorkerLease.Tests.ps1 @@ -0,0 +1,58 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Invoke-NativeWorkerLease' -Tag 'PMU', 'PMUNativeHandoff', 'PMUCoverageFollowUp' { + BeforeEach { + $operation = Initialize-PMURecoveryFixture $TestDrive + $script:workerContext = [pscustomobject]@{ + Operation = $operation + ParentPid = $PID + ParentStarted = Get-RepositoryProcessStart $PID + Token = Get-NativeJournalToken $operation + } + $script:journalHash = (Get-FileHash $operation.JournalPath).Hash + } + It 'checks the selected installation, forwards the owned result and releases the original lock' { + Mock Assert-NativeSelectedInstallation {} + Mock Invoke-OwnedNativeUpdate { [pscustomobject]@{ Success = $true; Token = $workerContext.Token } } + Mock Write-NativeWorkerMessage {} + Invoke-NativeWorkerLease $workerContext + Should -Invoke Assert-NativeSelectedInstallation -Times 1 -Exactly -ParameterFilter { $Context -eq $workerContext } + Should -Invoke Invoke-OwnedNativeUpdate -Times 1 -Exactly -ParameterFilter { $Context -eq $workerContext } + Should -Invoke Write-NativeWorkerMessage -Times 1 -Exactly -ParameterFilter { + $Message.Success -and $Message.Token -eq $workerContext.Token + } + $lease = Enter-InstallationLock $operation.LockPath 50 + $lease.Dispose() + } + + It 'refuses mutation after the approving parent instance dies' { + $workerContext.ParentStarted = 'different process start' + { Invoke-NativeWorkerLease $workerContext } | Should -Throw -ErrorId 'PMU.PlanStale' + (Get-FileHash $operation.JournalPath).Hash | Should -BeExactly $journalHash + $lease = Enter-InstallationLock $operation.LockPath 50 + $lease.Dispose() + } + It 'refuses mutation when recovery or replacement won the parent-to-worker gap' -ForEach @( + @{ Winner = 'Recovery' } + @{ Winner = 'Replacement' } + ) { + if ($Winner -eq 'Recovery') { + Write-RecoveryJournalState $operation.Journal 'RecoveryCompleted' + } else { + $operation.Journal.TransactionId = [guid]::NewGuid().ToString('N') + Write-DurableJournal $operation.Journal + } + $expectedHash = (Get-FileHash $operation.JournalPath).Hash + { Invoke-NativeWorkerLease $workerContext } | Should -Throw -ErrorId 'PMU.PlanStale' + (Get-FileHash $operation.JournalPath).Hash | Should -BeExactly $expectedHash + Test-Path $operation.BackupPath | Should -BeTrue + } + It 'uses the exact old-format original lock file rather than a secondary worker-only gate' { + $legacyLease = [IO.File]::Open($operation.LockPath, 'OpenOrCreate', 'ReadWrite', 'None') + try { + { Invoke-NativeWorkerLease $workerContext } | Should -Throw -ErrorId 'PMU.LockTimeout' + (Get-FileHash $operation.JournalPath).Hash | Should -BeExactly $journalHash + } finally { + $legacyLease.Dispose() + } + } +} diff --git a/tests/private/installation/Invoke-NativeWorkerProcess.Tests.ps1 b/tests/private/installation/Invoke-NativeWorkerProcess.Tests.ps1 new file mode 100644 index 0000000..a6757e2 --- /dev/null +++ b/tests/private/installation/Invoke-NativeWorkerProcess.Tests.ps1 @@ -0,0 +1,86 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Invoke-NativeWorkerProcess' -Tag 'PMU', 'PMUWorkerCleanup' { + BeforeEach { + $script:parentLease = [IO.MemoryStream]::new() + $script:operation = @{ + Lock = $null + LockPath = Join-Path $TestDrive 'parent.lock' + } + $script:context = [pscustomobject]@{ Token = 'isolated-worker-token' } + $script:response = [pscustomobject]@{ Phase = 'RESULT'; Outcome = 'Installed' } + $process = [pscustomobject]@{ Disposed = $false } + $process | Add-Member -MemberType ScriptMethod -Name Dispose -Value { + $this.Disposed = $true + } + $script:worker = [pscustomobject]@{ Process = $process; ExitCode = $null } + $script:workerExitCode = 0 + $script:workerStopped = $false + Mock Invoke-NativeWorkerStart { $script:worker } + Mock Invoke-NativeWorkerExchange { $script:response } + Mock Wait-NativeWorkerStop { + $Worker.ExitCode = $script:workerExitCode + $script:workerStopped = $true + } + Mock Enter-InstallationLock { + $script:workerStopped | Should -BeTrue + $Path | Should -BeExactly $script:operation.LockPath + $script:parentLease + } + } + + AfterEach { + $script:parentLease.Dispose() + } + + It 'returns the successful exchange response after restoring parent ownership and disposing the process' { + $actual = @(Invoke-NativeWorkerProcess $operation $context) + + $actual.Count | Should -Be 1 + [object]::ReferenceEquals($actual[0], $response) | Should -BeTrue + [object]::ReferenceEquals($operation.Lock, $parentLease) | Should -BeTrue + $operation.Lock.CanRead | Should -BeTrue + $worker.Process.Disposed | Should -BeTrue + Should -Invoke Invoke-NativeWorkerStart -Times 1 -Exactly -ParameterFilter { + [object]::ReferenceEquals($Operation, $script:operation) -and + [object]::ReferenceEquals($Context, $script:context) + } + } + + It 'completes the lease and preserves the original exchange failure ' -ForEach @( + @{ FailureId = 'PMU.SourceMismatch' } + @{ FailureId = 'Fixture.ExchangeFailed' } + ) { + $script:exchangeException = [InvalidOperationException]::new('Exchange fixture failure') + $script:exchangeFailure = [Management.Automation.ErrorRecord]::new( + $script:exchangeException, $FailureId, 'InvalidOperation', $null + ) + $script:workerExitCode = 9 + Mock Invoke-NativeWorkerExchange { throw $script:exchangeFailure } + + $failure = { Invoke-NativeWorkerProcess $operation $context } | + Should -Throw -ErrorId $FailureId -PassThru + + [object]::ReferenceEquals($failure.Exception, $exchangeException) | Should -BeTrue + [object]::ReferenceEquals($operation.Lock, $parentLease) | Should -BeTrue + $operation.Lock.CanRead | Should -BeTrue + $worker.Process.Disposed | Should -BeTrue + } + + It 'rejects a completed exchange with an unsuccessful worker exit without emitting its response' { + $script:workerExitCode = 17 + $emitted = [Collections.Generic.List[object]]::new() + + $failure = { + Invoke-NativeWorkerProcess $operation $context | + ForEach-Object { $emitted.Add($_) } + } | Should -Throw -ErrorId 'PMU.RecoveryRequired' -PassThru + + $failure.Exception.Message | Should -Match 'Pending state was preserved' + $emitted.Count | Should -Be 0 + [object]::ReferenceEquals($operation.Lock, $parentLease) | Should -BeTrue + $worker.Process.Disposed | Should -BeTrue + } +} diff --git a/tests/private/installation/Invoke-OwnedNativeUpdate.Tests.ps1 b/tests/private/installation/Invoke-OwnedNativeUpdate.Tests.ps1 new file mode 100644 index 0000000..89507fc --- /dev/null +++ b/tests/private/installation/Invoke-OwnedNativeUpdate.Tests.ps1 @@ -0,0 +1,69 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Invoke-OwnedNativeUpdate' -Tag 'PMU', 'PMUWorkerProtocol' { + BeforeEach { + $operation = Initialize-PMURecoveryFixture $TestDrive + $script:context = [pscustomobject]@{ + Operation = $operation + AlreadyInstalled = $false + } + $script:approvedTree = @([pscustomobject]@{ + Path = 'verified-target' + SHA256 = 'verified-hash' + }) + Mock Initialize-StagedPackage { $script:approvedTree } + Mock Assert-NativeSelectedInstallation {} + Mock Confirm-NativeExistingTarget {} + Mock Invoke-ProviderInstallation {} + } + + It 'validates the selected installation before handling AlreadyInstalled=' -ForEach @( + @{ AlreadyInstalled = $false } + @{ AlreadyInstalled = $true } + ) { + $context.AlreadyInstalled = $AlreadyInstalled + $result = Invoke-OwnedNativeUpdate $context + $result.Success | Should -BeTrue + $result.Token.TransactionId | Should -BeExactly $operation.Journal.TransactionId + $operation.Journal.StagedTree[0].SHA256 | Should -BeExactly 'verified-hash' + Should -Invoke Confirm-NativeExistingTarget -Times ([int]$AlreadyInstalled) -Exactly + Should -Invoke Invoke-ProviderInstallation -Times ([int](-not $AlreadyInstalled)) -Exactly + } + + It 'returns an owned failure without installing when the selected installation changes' { + Mock Assert-NativeSelectedInstallation { + Write-PMUError 'PMU.PlanStale' 'The selected installation changed.' + } + $before = (Get-FileHash -LiteralPath $operation.JournalPath).Hash + $result = Invoke-OwnedNativeUpdate $context + $result.Success | Should -BeFalse + $result.ErrorId | Should -BeExactly 'PMU.PlanStale' + $result.Token.TransactionId | Should -BeExactly $operation.Journal.TransactionId + (Get-FileHash -LiteralPath $operation.JournalPath).Hash | Should -BeExactly $before + Should -Invoke Invoke-ProviderInstallation -Times 0 + Should -Invoke Confirm-NativeExistingTarget -Times 0 + } + + It 'preserves a staging failure without entering installed-tree validation' { + Mock Initialize-StagedPackage { + Write-PMUError 'PMU.SourceMismatch' 'The staged source changed.' + } + $result = Invoke-OwnedNativeUpdate $context + $result.Success | Should -BeFalse + $result.ErrorId | Should -BeExactly 'PMU.SourceMismatch' + Should -Invoke Assert-NativeSelectedInstallation -Times 0 + Should -Invoke Invoke-ProviderInstallation -Times 0 + } + + It 'returns an owned installation error instead of a successful result' { + Mock Invoke-ProviderInstallation { + Write-PMUError 'PMU.VerificationFailed' 'The installed tree did not match.' + } + $result = Invoke-OwnedNativeUpdate $context + $result.Success | Should -BeFalse + $result.ErrorId | Should -BeExactly 'PMU.VerificationFailed' + $result.Token.TransactionId | Should -BeExactly $operation.Journal.TransactionId + } +} diff --git a/tests/private/installation/Invoke-ProviderInstallation.Tests.ps1 b/tests/private/installation/Invoke-ProviderInstallation.Tests.ps1 index 79bacb3..5b05abb 100644 --- a/tests/private/installation/Invoke-ProviderInstallation.Tests.ps1 +++ b/tests/private/installation/Invoke-ProviderInstallation.Tests.ps1 @@ -1,12 +1,17 @@ -BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" + function Assert-NativeRepositoryBinding { + } +} Describe 'Invoke-ProviderInstallation' -Tag 'PMU' { - It 'records Verification and Completed only after independently verifying provider output' { + It 'leaves verified native output in Verification for the reacquired parent to complete' -Tag 'PMUNativeHandoff' { $operation = Initialize-PMURecoveryFixture $TestDrive Mock Assert-RepositoryBinding {} + Mock Assert-NativeRepositoryBinding {} Mock Install-ProviderResource {} Mock Assert-InstalledPackage {} Invoke-ProviderInstallation $operation @() Should -Invoke Assert-InstalledPackage -Times 1 - $operation.Journal.State | Should -Be 'Completed' + $operation.Journal.State | Should -Be 'Verification' } } diff --git a/tests/private/installation/Invoke-RepositoryInstallation.Tests.ps1 b/tests/private/installation/Invoke-RepositoryInstallation.Tests.ps1 index fcea2d0..0fd6677 100644 --- a/tests/private/installation/Invoke-RepositoryInstallation.Tests.ps1 +++ b/tests/private/installation/Invoke-RepositoryInstallation.Tests.ps1 @@ -1,5 +1,31 @@ -BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} Describe 'Invoke-RepositoryInstallation' -Tag 'PMUUriBridge' { + It 'opens the exact planned source and closes the leased transport after installation' { + $script:installationOperation = Initialize-PMUTestOperation $TestDrive + $script:transportLease = [pscustomobject]@{ Name = 'OwnedTransport' } + Mock Open-RepositoryTransport { $script:transportLease } + Mock Invoke-ScopedInstallation {} + Mock Close-RepositoryTransport {} + + Invoke-RepositoryInstallation $installationOperation + + $installationOperation.RepositoryName | Should -BeExactly 'OwnedTransport' + Should -Invoke Open-RepositoryTransport -Times 1 -Exactly -ParameterFilter { + $Source.Name -ceq $script:installationOperation.Plan.RepositoryName -and + $Source.Uri -ceq $script:installationOperation.Plan.RepositoryUri -and + $Source.ApiVersion -ceq $script:installationOperation.Plan.RepositoryApiVersion -and + $Source.Trusted -eq $script:installationOperation.Plan.RepositoryTrusted + } + Should -Invoke Invoke-ScopedInstallation -Times 1 -Exactly -ParameterFilter { + [object]::ReferenceEquals($Operation, $script:installationOperation) + } + Should -Invoke Close-RepositoryTransport -Times 1 -Exactly -ParameterFilter { + [object]::ReferenceEquals($Lease, $script:transportLease) -and $null -eq $Original + } + } + Context 'Close on installation failure' { It 'closes its transport with the original failure after the scoped workflow fails' { $operation = Initialize-PMUTestOperation $TestDrive diff --git a/tests/private/installation/Invoke-ScopedInstallation.Tests.ps1 b/tests/private/installation/Invoke-ScopedInstallation.Tests.ps1 index d4361ff..16c59e8 100644 --- a/tests/private/installation/Invoke-ScopedInstallation.Tests.ps1 +++ b/tests/private/installation/Invoke-ScopedInstallation.Tests.ps1 @@ -1,12 +1,35 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Invoke-ScopedInstallation' -Tag 'PMU' { Context 'Scope lock lifetime' { + It 'disposes the reacquired original-key lease rather than an obsolete pre-handoff handle' -Tag 'PMUNativeHandoff' { + $script:initialLease = [IO.MemoryStream]::new() + $script:reacquiredLease = [IO.MemoryStream]::new() + Mock Initialize-ControlledDirectory {} + Mock Enter-InstallationLock { $script:initialLease } + Mock Invoke-LockedInstallation { + $Operation.Lock.CanRead | Should -BeTrue + $Operation.Lock.Dispose() + $Operation.Lock = $script:reacquiredLease + } + $operation = @{ + DataRoot = $TestDrive + WorkRoot = $TestDrive + LockPath = "$TestDrive/lock" + } + Invoke-ScopedInstallation $operation + $initialLease.CanRead | Should -BeFalse + $reacquiredLease.CanRead | Should -BeFalse + } It 'releases an acquired handle even when the locked workflow fails' { $script:lease = [IO.MemoryStream]::new() Mock Initialize-ControlledDirectory {} Mock Enter-InstallationLock { $script:lease } Mock Invoke-LockedInstallation { throw [InvalidOperationException]::new('fixture failure') } - $operation = @{ DataRoot = $TestDrive; WorkRoot = $TestDrive; LockPath = "$TestDrive/lock" } + $operation = @{ + DataRoot = $TestDrive + WorkRoot = $TestDrive + LockPath = "$TestDrive/lock" + } { Invoke-ScopedInstallation $operation } | Should -Throw '*fixture failure*' $lease.CanRead | Should -BeFalse } diff --git a/tests/private/installation/Invoke-UpdateInstallation.Tests.ps1 b/tests/private/installation/Invoke-UpdateInstallation.Tests.ps1 index ec681dc..329ed0e 100644 --- a/tests/private/installation/Invoke-UpdateInstallation.Tests.ps1 +++ b/tests/private/installation/Invoke-UpdateInstallation.Tests.ps1 @@ -3,6 +3,7 @@ BeforeAll { . "$root/tests/TestHelpers/Import-PMUTestSource.ps1" . "$root/tests/TestHelpers/Initialize-PMUTestPackage.ps1" . "$root/tests/TestHelpers/Initialize-PMUInterruptedReplacement.ps1" + . "$root/tests/TestHelpers/Invoke-PMUNativeHandoffMock.ps1" } Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', 'PMURecoverySafety' { BeforeEach { @@ -13,12 +14,36 @@ Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', $script:old = Initialize-PMUTestPackage $modules '2.0.0-preview.1' $script:target = Initialize-PMUTestPackage (Join-Path $fixtureRoot 'package') '2.0.0-preview.2' $script:active = Get-Module -ListAvailable "$old/FixtureModule.psd1" - Mock Resolve-ModuleInstallationRoot { if ($Scope -eq 'CurrentUser') { $script:modules } else { $script:other } } + Mock Resolve-ModuleInstallationRoot { if ($Scope -eq 'CurrentUser') { + $script:modules + } else { + $script:other + } } Mock Resolve-PMUDataRoot { $script:data } - Mock Get-ProviderContext { [pscustomobject]@{ Name = 'Microsoft.PowerShell.PSResourceGet'; Version = [version]'1.2.0' } } - Mock Get-RepositorySnapshot { [pscustomobject]@{ Name = 'OwnedFeed'; Uri = 'https://example.invalid/feed'; ApiVersion = 'V3'; Trusted = $true } } + Mock Get-ProviderContext { [pscustomobject]@{ + Name = 'Microsoft.PowerShell.PSResourceGet' + Version = [version]'1.2.0' + } } + Mock Get-RepositorySnapshot { [pscustomobject]@{ + Name = 'OwnedFeed' + Uri = 'https://example.invalid/feed' + ApiVersion = 'V3' + Trusted = $true + } } + Mock Assert-NativeRepositoryBinding { + Assert-RepositoryBinding $Operation.Plan $Operation.RepositoryName + } + Mock Invoke-NativeHandoff { + Invoke-PMUNativeHandoffMock $Operation $AlreadyInstalled + } Mock Find-ProviderResource { - [pscustomobject]@{ Name = 'FixtureModule'; Version = [version]'2.0.0'; Prerelease = 'preview.2'; AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' }; Dependencies = @() } + [pscustomobject]@{ + Name = 'FixtureModule' + Version = [version]'2.0.0' + Prerelease = 'preview.2' + AdditionalMetadata = @{ RequireLicenseAcceptance = 'false' } + Dependencies = @() + } } Mock Save-ProviderResource { $parent = Join-Path $Operation.StageRoot 'FixtureModule' @@ -46,7 +71,13 @@ Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', } It 'previews a licensed exact target without accepting a license or writing state' { Mock Find-ProviderResource { - [pscustomobject]@{ Name = 'FixtureModule'; Version = [version]'2.0.0'; Prerelease = 'preview.2'; AdditionalMetadata = @{ RequireLicenseAcceptance = 'true' }; Dependencies = @() } + [pscustomobject]@{ + Name = 'FixtureModule' + Version = [version]'2.0.0' + Prerelease = 'preview.2' + AdditionalMetadata = @{ RequireLicenseAcceptance = 'true' } + Dependencies = @() + } } $licensed = Get-PSModuleUpdate -Module $active -Repository OwnedFeed -Policy @{ Channel = 'IncludePrerelease' } ($licensed | Install-PSModuleUpdate -WhatIf).Outcome | Should -Be 'WhatIf' @@ -61,8 +92,9 @@ Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', try { { $plan | Install-PSModuleUpdate -Confirm:$false } | Should -Throw -ErrorId 'PMU.PermissionDenied*' Should -Invoke Save-ProviderResource -Times 0 + } finally { + [IO.File]::SetUnixFileMode($parent, [IO.UnixFileMode]'UserRead,UserWrite,UserExecute') } - finally { [IO.File]::SetUnixFileMode($parent, [IO.UnixFileMode]'UserRead,UserWrite,UserExecute') } } It 'replaces only same-base identity, verifies and makes a second call a no-op' { $result = $plan | Install-PSModuleUpdate -Confirm:$false @@ -93,7 +125,12 @@ Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', { $plan | Install-PSModuleUpdate -Confirm:$false } | Should -Throw -ErrorId 'PMU.InvalidPlan*' } It 'rejects a changed repository' { - Mock Get-RepositorySnapshot { [pscustomobject]@{ Name = 'OwnedFeed'; Uri = 'https://elsewhere.invalid/feed'; ApiVersion = 'V3'; Trusted = $true } } + Mock Get-RepositorySnapshot { [pscustomobject]@{ + Name = 'OwnedFeed' + Uri = 'https://elsewhere.invalid/feed' + ApiVersion = 'V3' + Trusted = $true + } } { $plan | Install-PSModuleUpdate -Confirm:$false } | Should -Throw -ErrorId 'PMU.RepositoryChanged*' } It 'recovers through the public API with a missing target and a surviving older active module' { @@ -112,7 +149,9 @@ Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', $survivor = Initialize-PMUTestPackage $other '1.0.0-stablefixture' $activeModule = Get-Module -ListAvailable "$survivor/FixtureModule.psd1" $null = Initialize-PMUInterruptedReplacement $plan - if ($Damage -eq 'partial') { $null = New-Item $old -ItemType Directory } + if ($Damage -eq 'partial') { + $null = New-Item $old -ItemType Directory + } $next = Get-PSModuleUpdate -Module $activeModule -Repository OwnedFeed -Policy @{ Channel = 'IncludePrerelease' } { $next | Install-PSModuleUpdate -Confirm:$false } | Should -Throw -ErrorId 'PMU.PlanStale*' (Get-ManifestIdentity "$old/FixtureModule.psd1").Version.FullVersion | Should -Be '2.0.0-preview.1' @@ -155,7 +194,10 @@ Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', Test-Path $data | Should -BeFalse } It 'rejects an untrusted journal path without touching external evidence' { - $operation = ConvertTo-InstallationOperation $plan (Test-UpdatePlan $plan) @{ AcceptLicense = $true; TrustRepository = $true } $null + $operation = ConvertTo-InstallationOperation $plan (Test-UpdatePlan $plan) @{ + AcceptLicense = $true + TrustRepository = $true + } $null Initialize-ControlledDirectory $operation.WorkRoot $journal = ConvertTo-RecoveryJournal $operation @() $journal.BackupPath = Join-Path $fixtureRoot 'unrelated' @@ -166,7 +208,10 @@ Describe 'Invoke-UpdateInstallation' -Tag 'PMU', 'PMULicense', 'PMUPermission', } It 'handles AllUsers permission failure without falling back to CurrentUser' { $null = Initialize-PMUTestPackage $other '2.0.0-preview.1' - $allUsers = Get-PSModuleUpdate -Module $active -Repository OwnedFeed -Policy @{ Scope = 'AllUsers'; Channel = 'IncludePrerelease' } + $allUsers = Get-PSModuleUpdate -Module $active -Repository OwnedFeed -Policy @{ + Scope = 'AllUsers' + Channel = 'IncludePrerelease' + } Mock Install-ProviderResource { throw [UnauthorizedAccessException]::new('fixture denied') } { $allUsers | Install-PSModuleUpdate -Confirm:$false } | Should -Throw -ErrorId 'PMU.PermissionDenied*' (Get-ManifestIdentity "$old/FixtureModule.psd1").Version.FullVersion | Should -Be '2.0.0-preview.1' diff --git a/tests/private/installation/Read-NativeWorkerMessage.Tests.ps1 b/tests/private/installation/Read-NativeWorkerMessage.Tests.ps1 new file mode 100644 index 0000000..e1b80ce --- /dev/null +++ b/tests/private/installation/Read-NativeWorkerMessage.Tests.ps1 @@ -0,0 +1,10 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Read-NativeWorkerMessage' -Tag 'PMU', 'PMUNativeHandoff' { + It 'reads only the private protocol envelope without surfacing provider console diagnostics' { + $reader = [IO.StringReader]::new("provider diagnostic`nPMU_NATIVE:{`"Phase`":`"READY`"}`n") + (Read-NativeWorkerMessage $reader).Phase | Should -BeExactly 'READY' + } + It 'fails rather than inventing success after EOF' { + { Read-NativeWorkerMessage ([IO.StringReader]::new('')) } | Should -Throw -ErrorId 'PMU.RecoveryRequired' + } +} diff --git a/tests/private/installation/Resolve-InstallationFailure.Tests.ps1 b/tests/private/installation/Resolve-InstallationFailure.Tests.ps1 index f181adf..a145978 100644 --- a/tests/private/installation/Resolve-InstallationFailure.Tests.ps1 +++ b/tests/private/installation/Resolve-InstallationFailure.Tests.ps1 @@ -3,7 +3,11 @@ Describe 'Resolve-InstallationFailure' -Tag 'PMU' { It 'reports both installation and rollback failure while retaining evidence' { $operation = Initialize-PMURecoveryFixture $TestDrive Mock Restore-InstallationBackup { throw 'fixture recovery failure' } - try { throw 'fixture original failure' } catch { $failure = $_ } + try { + throw 'fixture original failure' + } catch { + $failure = $_ + } { Resolve-InstallationFailure $operation $failure } | Should -Throw -ErrorId 'PMU.RollbackFailed' (Read-RecoveryJournal $operation).State | Should -Be 'RecoveryRequired' Test-Path $operation.BackupPath | Should -BeTrue diff --git a/tests/private/installation/Restore-NativeParentLease.Tests.ps1 b/tests/private/installation/Restore-NativeParentLease.Tests.ps1 new file mode 100644 index 0000000..ecded58 --- /dev/null +++ b/tests/private/installation/Restore-NativeParentLease.Tests.ps1 @@ -0,0 +1,45 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Restore-NativeParentLease' -Tag 'PMU', 'PMUWorkerProtocol' { + BeforeEach { + $script:operation = Initialize-PMURecoveryFixture $TestDrive + } + + AfterEach { + if ($operation.Lock) { + $operation.Lock.Dispose() + } + } + + It 'reacquires the original installation lock when the parent no longer owns it' { + Restore-NativeParentLease $operation + $operation.Lock.Name | Should -BeExactly $operation.LockPath + $operation.Lock.CanRead | Should -BeTrue + { Enter-InstallationLock $operation.LockPath 50 } | Should -Throw -ErrorId 'PMU.LockTimeout' + } + + It 'retains an already owned original lease rather than replacing it' { + $lease = Enter-InstallationLock $operation.LockPath + $operation.Lock = $lease + Restore-NativeParentLease $operation + [object]::ReferenceEquals($operation.Lock, $lease) | Should -BeTrue + $lease.CanRead | Should -BeTrue + } + + It 'replaces a disposed lease with ownership of the original lock' { + $operation.Lock = Enter-InstallationLock $operation.LockPath + $operation.Lock.Dispose() + Restore-NativeParentLease $operation + $operation.Lock.Name | Should -BeExactly $operation.LockPath + $operation.Lock.CanRead | Should -BeTrue + } + + It 'rejects a live lease for another file instead of treating it as ownership' { + $other = Join-Path $TestDrive 'another-operation.lock' + $operation.Lock = [IO.File]::Open($other, 'OpenOrCreate', 'ReadWrite', 'None') + { Restore-NativeParentLease $operation } | Should -Throw -ErrorId 'PMU.PlanStale' + $operation.Lock.Name | Should -BeExactly $other + } +} diff --git a/tests/private/installation/Send-NativeWorkerGo.Tests.ps1 b/tests/private/installation/Send-NativeWorkerGo.Tests.ps1 new file mode 100644 index 0000000..667c608 --- /dev/null +++ b/tests/private/installation/Send-NativeWorkerGo.Tests.ps1 @@ -0,0 +1,50 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Send-NativeWorkerGo' -Tag 'PMU', 'PMUWorkerProtocol' { + BeforeEach { + $operation = Initialize-PMURecoveryFixture $TestDrive + $inputPipe = [pscustomobject]@{ + LockPath = $operation.LockPath + Received = $null + Lease = $null + Closed = $false + } + $inputPipe | Add-Member ScriptMethod WriteLine { + param($Value) + + $this.Lease = Enter-InstallationLock $this.LockPath 50 + $this.Received = $Value + } + $inputPipe | Add-Member ScriptMethod Close { $this.Closed = $true } + $script:worker = [pscustomobject]@{ Process = [pscustomobject]@{ StandardInput = $inputPipe } } + } + + AfterEach { + if ($inputPipe.Lease) { + $inputPipe.Lease.Dispose() + } + if ($operation.Lock) { + $operation.Lock.Dispose() + } + } + + It 'releases the original lease before delivering GO and closing the input pipe' { + $lease = Enter-InstallationLock $operation.LockPath + $operation.Lock = $lease + Send-NativeWorkerGo $operation $worker + $lease.CanRead | Should -BeFalse + $operation.Lock | Should -BeNullOrEmpty + $inputPipe.Lease.CanRead | Should -BeTrue + $inputPipe.Received | Should -BeExactly 'GO' + $inputPipe.Closed | Should -BeTrue + } + + It 'does not send permission when the parent does not own the original lease' { + { Send-NativeWorkerGo $operation $worker } | Should -Throw -ErrorId 'PMU.PlanStale' + $inputPipe.Received | Should -BeNullOrEmpty + $inputPipe.Lease | Should -BeNullOrEmpty + $inputPipe.Closed | Should -BeFalse + } +} diff --git a/tests/private/installation/Test-InstalledPlanState.Tests.ps1 b/tests/private/installation/Test-InstalledPlanState.Tests.ps1 index d8c2630..7f6807f 100644 --- a/tests/private/installation/Test-InstalledPlanState.Tests.ps1 +++ b/tests/private/installation/Test-InstalledPlanState.Tests.ps1 @@ -2,7 +2,10 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Test-InstalledPlanState' -Tag 'PMU' { It 'rejects a selected installed version changed after planning' { $operation = Initialize-PMUTestOperation $TestDrive - Mock Get-InstalledModuleIdentityList { [pscustomobject]@{ FullVersion = '9.0.0'; Guid = [guid]'a1111111-1111-1111-1111-111111111111' } } + Mock Get-InstalledModuleIdentityList { [pscustomobject]@{ + FullVersion = '9.0.0' + Guid = [guid]'a1111111-1111-1111-1111-111111111111' + } } { Test-InstalledPlanState $operation } | Should -Throw -ErrorId 'PMU.InstalledStateChanged' } } diff --git a/tests/private/installation/Test-RestartRequired.Tests.ps1 b/tests/private/installation/Test-RestartRequired.Tests.ps1 index dbd14aa..7996785 100644 --- a/tests/private/installation/Test-RestartRequired.Tests.ps1 +++ b/tests/private/installation/Test-RestartRequired.Tests.ps1 @@ -9,7 +9,10 @@ Describe 'Test-RestartRequired' -Tag 'PMU' { } It 'requires restart when loaded files were replaced even with unchanged version text' { $operation = Initialize-PMUTestOperation $TestDrive - $script:loadedFixture = [pscustomobject]@{ ModuleBase = $operation.Plan.TargetInstalledPath; Guid = $operation.Plan.ModuleGuid } + $script:loadedFixture = [pscustomobject]@{ + ModuleBase = $operation.Plan.TargetInstalledPath + Guid = $operation.Plan.ModuleGuid + } Mock Get-Module { $script:loadedFixture } $operation.Plan.ActiveVersion = $operation.Plan.TargetVersion Test-RestartRequired $operation.Plan 'Installed' | Should -BeTrue diff --git a/tests/private/installation/Wait-NativeWorkerStop.Tests.ps1 b/tests/private/installation/Wait-NativeWorkerStop.Tests.ps1 new file mode 100644 index 0000000..e82f0cc --- /dev/null +++ b/tests/private/installation/Wait-NativeWorkerStop.Tests.ps1 @@ -0,0 +1,46 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Wait-NativeWorkerStop' -Tag 'PMU', 'PMUWorkerProtocol' { + BeforeEach { + $inputPipe = [IO.StringWriter]::new() + $output = [IO.StringReader]::new('owned worker output') + $process = [pscustomobject]@{ + StandardInput = $inputPipe + StandardOutput = $output + ExitCode = $null + FinalExitCode = 0 + } + $process | Add-Member ScriptMethod WaitForExit { + $this.ExitCode = $this.FinalExitCode + } + $script:worker = [pscustomobject]@{ + Process = $process + Errors = [Threading.Tasks.Task]::FromResult('owned worker diagnostics') + ExitCode = $null + } + } + + AfterEach { + $inputPipe.Dispose() + $output.Dispose() + } + + It 'closes input and waits for termination before recording exit code ' -ForEach @( + @{ ExitCode = 0 } + @{ ExitCode = 23 } + ) { + $process.FinalExitCode = $ExitCode + @(Wait-NativeWorkerStop $worker).Count | Should -Be 0 + $worker.ExitCode | Should -Be $ExitCode + { $inputPipe.Write('after-close') } | Should -Throw + } + + It 'propagates error-stream collection failures instead of reporting successful completion' { + $failure = [InvalidOperationException]::new('Owned error stream failed.') + $worker.Errors = [Threading.Tasks.Task]::FromException[string]($failure) + { Wait-NativeWorkerStop $worker } | Should -Throw '*Owned error stream failed*' + $worker.ExitCode | Should -Be 0 + } +} diff --git a/tests/private/installation/Write-NativeWorkerError.Tests.ps1 b/tests/private/installation/Write-NativeWorkerError.Tests.ps1 new file mode 100644 index 0000000..c34ce0f --- /dev/null +++ b/tests/private/installation/Write-NativeWorkerError.Tests.ps1 @@ -0,0 +1,27 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Write-NativeWorkerError' -Tag 'PMU', 'PMUWorkerContracts' { + It 'preserves an owned PMU error identifier without echoing native details' { + $result = [pscustomobject]@{ + ErrorId = 'PMU.SourceMismatch' + Message = 'untrusted-provider-detail' + } + $failure = { Write-NativeWorkerError $result } | + Should -Throw -ErrorId 'PMU.SourceMismatch' -PassThru + $failure.Exception.Message | Should -Not -Match 'untrusted-provider-detail' + $failure.TargetObject.RecoveryGuidance | Should -Match 'Do not delete pending PMU transaction data' + } + + It 'maps an unowned or malformed identifier to a safe installation failure: ' -ForEach @( + @{ ErrorId = $null } + @{ ErrorId = '' } + @{ ErrorId = 'Provider.Failed' } + @{ ErrorId = 'PMU.SourceMismatch,untrusted-detail' } + @{ ErrorId = 'PMU.Source-Mismatch' } + ) { + { Write-NativeWorkerError ([pscustomobject]@{ ErrorId = $ErrorId }) } | + Should -Throw -ErrorId 'PMU.InstallationFailed' + } +} diff --git a/tests/private/installation/Write-NativeWorkerMessage.Tests.ps1 b/tests/private/installation/Write-NativeWorkerMessage.Tests.ps1 new file mode 100644 index 0000000..30503fd --- /dev/null +++ b/tests/private/installation/Write-NativeWorkerMessage.Tests.ps1 @@ -0,0 +1,28 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Write-NativeWorkerMessage' -Tag 'PMU', 'PMUWorkerContracts' { + It 'emits one framed JSON line while preserving nested values and special characters' { + $message = [pscustomobject]@{ + Status = 'Ready' + Token = [pscustomobject]@{ TransactionId = "quotes ' `" and`nnewline" } + } + $writer = [IO.StringWriter]::new() + $original = [Console]::Out + try { + [Console]::SetOut($writer) + Write-NativeWorkerMessage $message + } finally { + [Console]::SetOut($original) + $writer.Dispose() + } + + $line = $writer.ToString() + $line | Should -Match '^PMU_NATIVE:' + @($line.TrimEnd([char[]]"`r`n") -split '\r?\n').Count | Should -Be 1 + $decoded = $line.Substring('PMU_NATIVE:'.Length) | ConvertFrom-Json + $decoded.Status | Should -BeExactly 'Ready' + $decoded.Token.TransactionId | Should -BeExactly $message.Token.TransactionId + } +} diff --git a/tests/private/locking/Assert-WindowsAccessRule.Tests.ps1 b/tests/private/locking/Assert-WindowsAccessRule.Tests.ps1 index 3716fc1..8c0a00f 100644 --- a/tests/private/locking/Assert-WindowsAccessRule.Tests.ps1 +++ b/tests/private/locking/Assert-WindowsAccessRule.Tests.ps1 @@ -2,7 +2,11 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Assert-WindowsAccessRule' -Tag 'PMU' { Context 'Trusted scope writers' { It 'allows read-only access for unrelated accounts without granting mutation rights' { - { Assert-WindowsAccessRule @{ Allowed = $true; CanWrite = $false; Identity = 'reader' } @('owner') } | Should -Not -Throw + { Assert-WindowsAccessRule @{ + Allowed = $true + CanWrite = $false + Identity = 'reader' + } @('owner') } | Should -Not -Throw } } } diff --git a/tests/private/locking/Assert-WindowsDirectoryDescriptor.Tests.ps1 b/tests/private/locking/Assert-WindowsDirectoryDescriptor.Tests.ps1 index e167a9f..d183872 100644 --- a/tests/private/locking/Assert-WindowsDirectoryDescriptor.Tests.ps1 +++ b/tests/private/locking/Assert-WindowsDirectoryDescriptor.Tests.ps1 @@ -2,15 +2,35 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Assert-WindowsDirectoryDescriptor' -Tag 'PMU', 'PMUSafety' { Context 'Scope data ACL' { It 'accepts a trusted owner with unrelated read-only access' { - $descriptor = @{ Owner = 'owner'; Trusted = @('owner'); Access = @(@{ Identity = 'reader'; CanWrite = $false; Allowed = $true }) } + $descriptor = @{ + Owner = 'owner' + Trusted = @('owner') + Access = @(@{ + Identity = 'reader' + CanWrite = $false + Allowed = $true + }) + } { Assert-WindowsDirectoryDescriptor $descriptor } | Should -Not -Throw } It 'rejects broadly writable Windows state even when its owner is trusted' { - $descriptor = @{ Owner = 'owner'; Trusted = @('owner'); Access = @(@{ Identity = 'everyone'; CanWrite = $true; Allowed = $true }) } + $descriptor = @{ + Owner = 'owner' + Trusted = @('owner') + Access = @(@{ + Identity = 'everyone' + CanWrite = $true + Allowed = $true + }) + } { Assert-WindowsDirectoryDescriptor $descriptor } | Should -Throw -ErrorId 'PMU.PermissionDenied' } It 'rejects an unrelated owner even when write ACLs appear private' { - $descriptor = @{ Owner = 'untrusted'; Trusted = @('owner'); Access = @() } + $descriptor = @{ + Owner = 'untrusted' + Trusted = @('owner') + Access = @() + } { Assert-WindowsDirectoryDescriptor $descriptor } | Should -Throw -ErrorId 'PMU.PermissionDenied' } } diff --git a/tests/private/locking/ConvertFrom-LinuxProcessStat.Tests.ps1 b/tests/private/locking/ConvertFrom-LinuxProcessStat.Tests.ps1 new file mode 100644 index 0000000..e337d3d --- /dev/null +++ b/tests/private/locking/ConvertFrom-LinuxProcessStat.Tests.ps1 @@ -0,0 +1,11 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'ConvertFrom-LinuxProcessStat' -Tag 'PMUUriBridge' { + It 'reads field 22 independently of spaces, parentheses and newlines in the command name' { + $stat = "7 (worker ) with`n spaces) S " + ('0 ' * 18) + '123451 0 0' + ConvertFrom-LinuxProcessStat $stat | Should -Be 123451 + } + It 'rejects incomplete process metadata' { + { ConvertFrom-LinuxProcessStat '7 (worker) S' } | + Should -Throw -ExceptionType ([IO.InvalidDataException]) + } +} diff --git a/tests/private/locking/ConvertTo-LinuxProcessStartIdentity.Tests.ps1 b/tests/private/locking/ConvertTo-LinuxProcessStartIdentity.Tests.ps1 new file mode 100644 index 0000000..72cbbc1 --- /dev/null +++ b/tests/private/locking/ConvertTo-LinuxProcessStartIdentity.Tests.ps1 @@ -0,0 +1,24 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'ConvertTo-LinuxProcessStartIdentity' -Tag 'PMUUriBridge' { + It 'uses the reported clock frequency without losing the kernel start counter at Hz' -ForEach @( + @{ Frequency = 100; Counter = 12345; Expected = '117900001234500000' } + @{ Frequency = 1000; Counter = 123451; Expected = '117900001234510000' } + ) { + $stat = '7 (worker ) with spaces) S ' + ('0 ' * 18) + "$Counter 0 0" + ConvertTo-LinuxProcessStartIdentity $stat "cpu 0`nbtime 1790000000`n" $Frequency | + Should -BeExactly $Expected + } + It 'rejects invalid kernel clock metadata rather than guessing' -ForEach @( + @{ Boot = ''; Frequency = '100' } + @{ Boot = 'btime 1790000000'; Frequency = '0' } + @{ Boot = 'btime 1790000000'; Frequency = '3' } + @{ Boot = 'btime 1790000000'; Frequency = 'not-a-frequency' } + ) { + $stat = '7 (worker) S ' + ('0 ' * 18) + '12345' + { ConvertTo-LinuxProcessStartIdentity $stat $Boot $Frequency } | Should -Throw -ExceptionType ([IO.InvalidDataException]) + } + It 'rejects incomplete process metadata' { + { ConvertTo-LinuxProcessStartIdentity '7 (worker) S' 'btime 1790000000' 100 } | + Should -Throw -ExceptionType ([IO.InvalidDataException]) + } +} diff --git a/tests/private/locking/ConvertTo-WindowsAccessRule.Tests.ps1 b/tests/private/locking/ConvertTo-WindowsAccessRule.Tests.ps1 index 5787f8a..ccc3604 100644 --- a/tests/private/locking/ConvertTo-WindowsAccessRule.Tests.ps1 +++ b/tests/private/locking/ConvertTo-WindowsAccessRule.Tests.ps1 @@ -2,7 +2,11 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'ConvertTo-WindowsAccessRule' -Tag 'PMU' { Context 'Windows write rights' { It 'distinguishes read-only and mutation permissions' { - $rule = @{ IdentityReference = @{ Value = 'fixture-sid' }; AccessControlType = 'Allow'; FileSystemRights = [Security.AccessControl.FileSystemRights]::Read } + $rule = @{ + IdentityReference = @{ Value = 'fixture-sid' } + AccessControlType = 'Allow' + FileSystemRights = [Security.AccessControl.FileSystemRights]::Read + } (ConvertTo-WindowsAccessRule $rule).CanWrite | Should -BeFalse $rule.FileSystemRights = [Security.AccessControl.FileSystemRights]::Modify (ConvertTo-WindowsAccessRule $rule).CanWrite | Should -BeTrue diff --git a/tests/private/locking/Enter-InstallationLock.Tests.ps1 b/tests/private/locking/Enter-InstallationLock.Tests.ps1 index 1dfde9e..1cf001b 100644 --- a/tests/private/locking/Enter-InstallationLock.Tests.ps1 +++ b/tests/private/locking/Enter-InstallationLock.Tests.ps1 @@ -6,8 +6,11 @@ Describe 'Enter-InstallationLock' -Tag 'PMU' { It 'uses an exclusive handle with a bounded timeout, not file existence' { $path = Join-Path $TestDrive 'owned.lock' $first = Enter-InstallationLock $path 100 - try { { Enter-InstallationLock $path 100 } | Should -Throw -ErrorId 'PMU.LockTimeout' } - finally { $first.Dispose() } + try { + { Enter-InstallationLock $path 100 } | Should -Throw -ErrorId 'PMU.LockTimeout' + } finally { + $first.Dispose() + } $second = Enter-InstallationLock $path 100 $second.Dispose() Test-Path $path | Should -BeTrue diff --git a/tests/private/locking/Enter-RepositoryRegistryLock.Tests.ps1 b/tests/private/locking/Enter-RepositoryRegistryLock.Tests.ps1 index d648e30..336a33d 100644 --- a/tests/private/locking/Enter-RepositoryRegistryLock.Tests.ps1 +++ b/tests/private/locking/Enter-RepositoryRegistryLock.Tests.ps1 @@ -4,8 +4,11 @@ Describe 'Enter-RepositoryRegistryLock' -Tag 'PMUUriBridge' { It 'rejects a competing handle and permits acquisition after disposal' { $root = Join-Path $TestDrive 'registry' $first = Enter-RepositoryRegistryLock $root - try { { Enter-RepositoryRegistryLock $root 50 } | Should -Throw -ErrorId 'PMU.LockTimeout*' } - finally { $first.Dispose() } + try { + { Enter-RepositoryRegistryLock $root 50 } | Should -Throw -ErrorId 'PMU.LockTimeout*' + } finally { + $first.Dispose() + } $next = Enter-RepositoryRegistryLock $root 50 $next.CanWrite | Should -BeTrue $next.Dispose() diff --git a/tests/private/locking/Get-InstallationLockIdentity.Tests.ps1 b/tests/private/locking/Get-InstallationLockIdentity.Tests.ps1 index f95a893..5b26664 100644 --- a/tests/private/locking/Get-InstallationLockIdentity.Tests.ps1 +++ b/tests/private/locking/Get-InstallationLockIdentity.Tests.ps1 @@ -1,7 +1,11 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Get-InstallationLockIdentity' -Tag 'PMU', 'PMULockIdentity' { It 'keys ownership by module, scope, and resolved root' { - $plan = @{ ModuleName = 'Fixture'; Scope = 'CurrentUser'; DestinationRoot = '/owned/one' } + $plan = @{ + ModuleName = 'Fixture' + Scope = 'CurrentUser' + DestinationRoot = '/owned/one' + } $first = Get-InstallationLockIdentity $plan $plan.Scope = 'AllUsers' Get-InstallationLockIdentity $plan | Should -Not -Be $first @@ -10,7 +14,11 @@ Describe 'Get-InstallationLockIdentity' -Tag 'PMU', 'PMULockIdentity' { Get-InstallationLockIdentity $plan | Should -Not -Be $first } It 'conservatively shares a lock for differently cased Windows and macOS root aliases' -Skip:(-not ($IsWindows -or $IsMacOS)) { - $plan = @{ ModuleName = 'Fixture'; Scope = 'CurrentUser'; DestinationRoot = '/Owned/Modules' } + $plan = @{ + ModuleName = 'Fixture' + Scope = 'CurrentUser' + DestinationRoot = '/Owned/Modules' + } $first = Get-InstallationLockIdentity $plan $plan.DestinationRoot = '/owned/modules' Get-InstallationLockIdentity $plan | Should -Be $first diff --git a/tests/private/locking/Get-LinuxRepositoryProcessStart.Tests.ps1 b/tests/private/locking/Get-LinuxRepositoryProcessStart.Tests.ps1 new file mode 100644 index 0000000..9333cb7 --- /dev/null +++ b/tests/private/locking/Get-LinuxRepositoryProcessStart.Tests.ps1 @@ -0,0 +1,14 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Get-LinuxRepositoryProcessStart' -Tag 'PMUUriBridge' { + It 'reads a live kernel identity and reports a missing kernel PID without consulting a reconstructed date' { + if ($IsLinux) { + Get-LinuxRepositoryProcessStart $PID | Should -Match '^1[0-9]{17}$' + Get-LinuxRepositoryProcessStart ([int]::MaxValue) | Should -BeNullOrEmpty + } else { + # The platform adapter must not consult procfs on other supported hosts. + Mock Get-LinuxRepositoryProcessStart { throw 'Linux adapter must not run here.' } + Get-PlatformRepositoryProcessStart $PID | Should -Match '^[0-9]{18}$' + Should -Invoke Get-LinuxRepositoryProcessStart -Times 0 + } + } +} diff --git a/tests/private/locking/Get-PlatformRepositoryProcessStart.Tests.ps1 b/tests/private/locking/Get-PlatformRepositoryProcessStart.Tests.ps1 new file mode 100644 index 0000000..d646669 --- /dev/null +++ b/tests/private/locking/Get-PlatformRepositoryProcessStart.Tests.ps1 @@ -0,0 +1,44 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Get-PlatformRepositoryProcessStart' -Tag 'PMUUriBridge', 'PMUCoverage' { + It 'preserves the native non-Linux process start timestamp and routes Linux to kernel metadata' { + $process = [Diagnostics.Process]::GetCurrentProcess() + try { + if ($IsLinux) { + Mock Get-LinuxRepositoryProcessStart { '117900001234500000' } + Get-PlatformRepositoryProcessStart $PID | Should -BeExactly '117900001234500000' + Should -Invoke Get-LinuxRepositoryProcessStart -Times 1 -ParameterFilter { $ProcessId -eq $PID } + } else { + Get-PlatformRepositoryProcessStart $PID | Should -BeExactly "$($process.StartTime.ToUniversalTime().Ticks)" + } + } finally { + $process.Dispose() + } + } + + Context 'Platform routing' { + BeforeEach { + $script:originalLinux = $IsLinux + Mock Get-LinuxRepositoryProcessStart { '117900001234500000' } + } + AfterEach { + Set-Variable IsLinux $script:originalLinux -Force + } + + It 'returns the Linux kernel timestamp without native timestamp conversion' { + Set-Variable IsLinux $true -Force + Get-PlatformRepositoryProcessStart $PID | Should -BeExactly '117900001234500000' + Should -Invoke Get-LinuxRepositoryProcessStart -Times 1 -Exactly -ParameterFilter { $ProcessId -eq $PID } + } + + It 'returns UTC start ticks without consulting Linux metadata on other platforms' { + Set-Variable IsLinux $false -Force + $process = [Diagnostics.Process]::GetCurrentProcess() + try { + Get-PlatformRepositoryProcessStart $PID | Should -BeExactly "$($process.StartTime.ToUniversalTime().Ticks)" + Should -Invoke Get-LinuxRepositoryProcessStart -Times 0 + } finally { + $process.Dispose() + } + } + } +} diff --git a/tests/private/locking/Get-RepositoryProcessStart.Tests.ps1 b/tests/private/locking/Get-RepositoryProcessStart.Tests.ps1 index 2bb369f..0c4294c 100644 --- a/tests/private/locking/Get-RepositoryProcessStart.Tests.ps1 +++ b/tests/private/locking/Get-RepositoryProcessStart.Tests.ps1 @@ -1,9 +1,51 @@ -BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } -Describe 'Get-RepositoryProcessStart' -Tag 'PMUUriBridge' { +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" + $entry = "$PSScriptRoot/../../../src/private/locking/Get-RepositoryProcessStart.ps1" + $sources = @($entry) + @(Get-PMUTestSourcePath $entry) + $imports = $sources | ForEach-Object { ". '$($_.Replace("'", "''"))'" } + $script:identityCommand = ($imports -join "`n") + "`nGet-RepositoryProcessStart $PID" +} +Describe 'Get-RepositoryProcessStart' -Tag 'PMUUriBridge', 'PMUCoverageFollowUp' { + It 'returns no owner identity when the platform reports an absent process' { + Mock Get-PlatformRepositoryProcessStart { throw [ArgumentException]::new('Process does not exist.') } + Get-RepositoryProcessStart 1234 | Should -BeNullOrEmpty + Should -Invoke Get-PlatformRepositoryProcessStart -Times 1 -Exactly -ParameterFilter { $ProcessId -eq 1234 } + } + + It 'requires recovery when the platform cannot verify a receipt owner' { + Mock Get-PlatformRepositoryProcessStart { throw [InvalidOperationException]::new('Untrusted process detail.') } + { Get-RepositoryProcessStart 1234 } | + Should -Throw -ErrorId 'PMU.RepositoryRecoveryRequired' -ExpectedMessage 'Cannot verify the repository receipt owner process.' + } + Context 'PID reuse resistant owner identity' { - It 'reports current process UTC ticks and absence of a nonexistent PID' { + It 'reports an 18-digit current process identity and absence of a nonexistent PID' { Get-RepositoryProcessStart $PID | Should -Match '^[0-9]{18}$' Get-RepositoryProcessStart ([int]::MaxValue) | Should -BeNullOrEmpty } + It 'reports exactly the same live parent identity from three fresh processes' { + $expected = Get-RepositoryProcessStart $PID + foreach ($observer in 1..3) { + $child = [Diagnostics.Process]::Start((Get-PMUIdentityProbeStartInfo $identityCommand)) + try { + $result = Receive-PMUChildProcess $child + $result.ExitCode | Should -Be 0 -Because $result.Error + $result.Output.Trim() | Should -BeExactly $expected + } finally { + Close-PMUChildProcess @($child) + } + } + } + It 'reports absence after a known live process exits' { + $child = [Diagnostics.Process]::Start((Get-PMUIdentityProbeStartInfo '$null = [Console]::In.ReadLine()')) + try { + Get-RepositoryProcessStart $child.Id | Should -Match '^[0-9]{18}$' + $child.Kill() + $child.WaitForExit(10000) | Should -BeTrue + Get-RepositoryProcessStart $child.Id | Should -BeNullOrEmpty + } finally { + Close-PMUChildProcess @($child) + } + } } } diff --git a/tests/private/locking/Get-WindowsDirectoryDescriptor.Tests.ps1 b/tests/private/locking/Get-WindowsDirectoryDescriptor.Tests.ps1 index 703d140..bb05dd3 100644 --- a/tests/private/locking/Get-WindowsDirectoryDescriptor.Tests.ps1 +++ b/tests/private/locking/Get-WindowsDirectoryDescriptor.Tests.ps1 @@ -1,9 +1,61 @@ -BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } -Describe 'Get-WindowsDirectoryDescriptor' -Tag 'PMU' { +BeforeAll { + class PMUTestWindowsIdentity { + static [object] $Current + static [object] GetCurrent() { return [PMUTestWindowsIdentity]::Current } + } + if (-not $IsWindows) { + # Substitute only the Windows-only API boundary before loading the real source. + $script:identityTypes = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + $script:originalIdentityType = $identityTypes::Get['Security.Principal.WindowsIdentity'] + $identityTypes::Add('Security.Principal.WindowsIdentity', [PMUTestWindowsIdentity]) + $script:typeCache = [psobject].Assembly.GetType('System.Management.Automation.Language.TypeCache'). + GetField('s_cache', [Reflection.BindingFlags]'Static,NonPublic').GetValue($null) + $typeCache.Clear() + # Get-Acl is absent on Unix; provide a fail-closed target for Pester's mock. + $null = New-Item Function:Get-Acl -Value { + [CmdletBinding()] + param([string]$LiteralPath) + throw "Unexpected unmocked ACL read: $LiteralPath" + } + } + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" + if (-not $IsWindows) { + # Reparse cached native calls, retaining canonical source extents for coverage. + $source = (Resolve-Path "$PSScriptRoot/../../../src/private/locking/Get-WindowsDirectoryDescriptor.ps1").Path + . ([Management.Automation.Language.Parser]::ParseFile($source, [ref]$null, [ref]$null).GetScriptBlock()) + } +} +AfterAll { + if (-not $IsWindows) { + $null = $identityTypes::Remove('Security.Principal.WindowsIdentity') + if ($originalIdentityType) { + $identityTypes::Add('Security.Principal.WindowsIdentity', $originalIdentityType) + } + $typeCache.Clear() + } +} +Describe 'Get-WindowsDirectoryDescriptor' -Tag 'PMU', 'PMUCoverage' { Context 'Native Windows descriptor' { It 'reads current-user ACL metadata from a disposable directory' -Skip:(-not $IsWindows) { $descriptor = Get-WindowsDirectoryDescriptor $TestDrive $descriptor.Trusted | Should -Contain $descriptor.Owner } } + + It 'propagates ACL read failures after releasing the current Windows identity' { + $identity = [pscustomobject]@{ + User = [pscustomobject]@{ Value = 'S-1-5-21-1234' } + Disposed = $false + } + $identity | Add-Member ScriptMethod Dispose { $this.Disposed = $true } + [PMUTestWindowsIdentity]::Current = $identity + Mock Get-Acl { + if (-not $IsWindows) { + [PMUTestWindowsIdentity]::Current.Disposed | Should -BeTrue + } + throw [IO.IOException]::new('ACL read failed.') + } + { Get-WindowsDirectoryDescriptor $TestDrive } | Should -Throw -ExpectedMessage 'ACL read failed.' + Should -Invoke Get-Acl -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq $TestDrive -and $ErrorAction -eq 'Stop' } + } } diff --git a/tests/private/locking/Initialize-WindowsControlledDirectory.Tests.ps1 b/tests/private/locking/Initialize-WindowsControlledDirectory.Tests.ps1 index d188d8f..376f6be 100644 --- a/tests/private/locking/Initialize-WindowsControlledDirectory.Tests.ps1 +++ b/tests/private/locking/Initialize-WindowsControlledDirectory.Tests.ps1 @@ -2,7 +2,11 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Initialize-WindowsControlledDirectory' -Tag 'PMU' { Context 'Protected Windows state' { It 'requires a trusted descriptor before allowing subsequent writes' { - Mock Get-WindowsDirectoryDescriptor { @{ Owner = 'owner'; Trusted = @('owner'); Access = @() } } + Mock Get-WindowsDirectoryDescriptor { @{ + Owner = 'owner' + Trusted = @('owner') + Access = @() + } } $path = Join-Path $TestDrive 'owned' Initialize-WindowsControlledDirectory $path Test-Path $path | Should -BeTrue diff --git a/tests/private/locking/Resolve-PMUDataRoot.Tests.ps1 b/tests/private/locking/Resolve-PMUDataRoot.Tests.ps1 index 6eeee19..9be60f3 100644 --- a/tests/private/locking/Resolve-PMUDataRoot.Tests.ps1 +++ b/tests/private/locking/Resolve-PMUDataRoot.Tests.ps1 @@ -1,5 +1,5 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } -Describe 'Resolve-PMUDataRoot' -Tag 'PMU' { +Describe 'Resolve-PMUDataRoot' -Tag 'PMU', 'PMUCoverage' { It 'uses scope-specific state locations outside module trees without creating them' { $user = Resolve-PMUDataRoot 'CurrentUser' $all = Resolve-PMUDataRoot 'AllUsers' @@ -7,4 +7,36 @@ Describe 'Resolve-PMUDataRoot' -Tag 'PMU' { $user | Should -Not -Match '[/\\]Modules[/\\]' $all | Should -Not -Match '[/\\]Modules[/\\]' } + + Context 'Platform-specific roots' { + BeforeEach { + $script:originalWindows = $IsWindows + $script:originalMacOS = $IsMacOS + Mock Assert-SafePath { $Path } + } + AfterEach { + Set-Variable IsWindows $script:originalWindows -Force + Set-Variable IsMacOS $script:originalMacOS -Force + } + + It 'uses for Windows state' -ForEach @( + @{ Scope = 'CurrentUser'; Folder = 'LocalApplicationData' } + @{ Scope = 'AllUsers'; Folder = 'CommonApplicationData' } + ) { + Set-Variable IsWindows $true -Force + $expected = Join-Path ([Environment]::GetFolderPath($Folder)) 'PSModuleUpdate' + Resolve-PMUDataRoot $Scope | Should -BeExactly $expected + Should -Invoke Assert-SafePath -Times 1 -Exactly -ParameterFilter { $Path -ceq $expected } + } + + It 'uses for non-Windows AllUsers state' -ForEach @( + @{ MacOS = $true; Expected = '/Library/Application Support/PSModuleUpdate' } + @{ MacOS = $false; Expected = '/var/lib/PSModuleUpdate' } + ) { + Set-Variable IsWindows $false -Force + Set-Variable IsMacOS $MacOS -Force + Resolve-PMUDataRoot 'AllUsers' | Should -BeExactly $Expected + Should -Invoke Assert-SafePath -Times 1 -Exactly -ParameterFilter { $Path -ceq $Expected } + } + } } diff --git a/tests/private/locking/Resolve-RepositoryTransportRoot.Tests.ps1 b/tests/private/locking/Resolve-RepositoryTransportRoot.Tests.ps1 index 10439b4..3819c6e 100644 --- a/tests/private/locking/Resolve-RepositoryTransportRoot.Tests.ps1 +++ b/tests/private/locking/Resolve-RepositoryTransportRoot.Tests.ps1 @@ -11,7 +11,9 @@ Describe 'Resolve-RepositoryTransportRoot' -Tag 'PMUUriBridge' { $first.Home | Should -Not -Be $second.Home foreach ($probe in @($first, $second)) { $expectedData = [Environment]::GetFolderPath('LocalApplicationData') - if ($IsLinux) { $expectedData = Join-Path $probe.Home '.local/share' } + if ($IsLinux) { + $expectedData = Join-Path $probe.Home '.local/share' + } $probe.LocalData | Should -Be $expectedData $probe.Store | Should -Be (Join-Path $expectedData 'PSResourceGet/PSResourceRepository.xml') $probe.Root | Should -Be (Join-Path $expectedData 'PSModuleUpdate/RepositoryTransport') @@ -22,7 +24,9 @@ Describe 'Resolve-RepositoryTransportRoot' -Tag 'PMUUriBridge' { $first = Get-PMUNativeRegistryRootProbe (Join-Path $TestDrive 'first-home') $data $second = Get-PMUNativeRegistryRootProbe (Join-Path $TestDrive 'second-home') $data $expectedData = [Environment]::GetFolderPath('LocalApplicationData') - if ($IsLinux) { $expectedData = $data } + if ($IsLinux) { + $expectedData = $data + } $first.Home | Should -Not -Be $second.Home foreach ($probe in @($first, $second)) { $probe.LocalData | Should -Be $expectedData diff --git a/tests/private/planning/Complete-PlanningContext.Tests.ps1 b/tests/private/planning/Complete-PlanningContext.Tests.ps1 index 7bd7e63..6896ac0 100644 --- a/tests/private/planning/Complete-PlanningContext.Tests.ps1 +++ b/tests/private/planning/Complete-PlanningContext.Tests.ps1 @@ -3,8 +3,21 @@ Describe 'Complete-PlanningContext' -Tag 'PMUUriBridge' { Context 'Transport discovery uses only its operation name' { It 'forwards the lease name and not the null public repository name' { $fixture = Initialize-PMURepositoryFixture $TestDrive - $context = [pscustomobject]@{ Module = $fixture.Module; Reference = [pscustomobject]@{ Guid = $fixture.Guid }; Policy = @{ Scope = 'CurrentUser'; Channel = 'Stable'; VersionRange = $null } } - Mock Get-PlanningInstallationState { [pscustomobject]@{ Observed = @(); Installed = $null; InstalledTree = @(); RecoveryJournal = $null } } + $context = [pscustomobject]@{ + Module = $fixture.Module + Reference = [pscustomobject]@{ Guid = $fixture.Guid } + Policy = @{ + Scope = 'CurrentUser' + Channel = 'Stable' + VersionRange = $null + } + } + Mock Get-PlanningInstallationState { [pscustomobject]@{ + Observed = @() + Installed = $null + InstalledTree = @() + RecoveryJournal = $null + } } Mock Assert-ReferenceInstallationLayout { } Mock Find-ProviderResource { } Mock Select-UpdateTarget { $null } diff --git a/tests/private/planning/ConvertTo-UpdateDecision.Tests.ps1 b/tests/private/planning/ConvertTo-UpdateDecision.Tests.ps1 new file mode 100644 index 0000000..82cd919 --- /dev/null +++ b/tests/private/planning/ConvertTo-UpdateDecision.Tests.ps1 @@ -0,0 +1,51 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'ConvertTo-UpdateDecision' -Tag 'PMU' { + It 'maps to the existing flat decision contract' -ForEach @( + @{ + Disposition = 'UpToDate' + Available = $false + Restart = $false + Reason = 'No release newer than both actual active and installed versions is available.' + } + @{ + Disposition = 'UpdateAvailable' + Available = $true + Restart = $false + Reason = 'An exact newer release is available.' + } + @{ + Disposition = 'AlreadyInstalledRestartRequired' + Available = $false + Restart = $true + Reason = 'A newer version is already installed in the selected scope.' + } + ) { + $decision = ConvertTo-UpdateDecision $Disposition + + $decision.GetType() | Should -Be ([hashtable]) + ($decision.Keys | Sort-Object) -join ',' | Should -BeExactly 'Available,Disposition,Reason,Restart' + $decision.Disposition | Should -BeExactly $Disposition + $decision.Available | Should -Be $Available + $decision.Restart | Should -Be $Restart + $decision.Reason | Should -BeExactly $Reason + $decision.Available | Should -BeOfType ([bool]) + $decision.Restart | Should -BeOfType ([bool]) + } + + It 'rejects a disposition outside the exact domain: ' -ForEach @( + @{ + Disposition = 'updateavailable' + } + @{ + Disposition = 'Unknown' + } + @{ + Disposition = '' + } + ) { + { ConvertTo-UpdateDecision $Disposition } | Should -Throw + } +} diff --git a/tests/private/planning/ConvertTo-UpdatePolicy.Tests.ps1 b/tests/private/planning/ConvertTo-UpdatePolicy.Tests.ps1 index b301288..1671c7c 100644 --- a/tests/private/planning/ConvertTo-UpdatePolicy.Tests.ps1 +++ b/tests/private/planning/ConvertTo-UpdatePolicy.Tests.ps1 @@ -14,6 +14,9 @@ Describe 'ConvertTo-UpdatePolicy' -Tag 'PMU' { { ConvertTo-UpdatePolicy @{ Force = $true } } | Should -Throw -ErrorId 'PMU.InvalidRequest' } It 'accepts explicit AllUsers and prerelease policy' { - (ConvertTo-UpdatePolicy @{ Scope = 'AllUsers'; Channel = 'IncludePrerelease' }).Scope | Should -Be 'AllUsers' + (ConvertTo-UpdatePolicy @{ + Scope = 'AllUsers' + Channel = 'IncludePrerelease' + }).Scope | Should -Be 'AllUsers' } } diff --git a/tests/private/planning/Get-PlanningContext.Tests.ps1 b/tests/private/planning/Get-PlanningContext.Tests.ps1 index 4cadfe0..7c6798c 100644 --- a/tests/private/planning/Get-PlanningContext.Tests.ps1 +++ b/tests/private/planning/Get-PlanningContext.Tests.ps1 @@ -4,10 +4,23 @@ Describe 'Get-PlanningContext' -Tag 'PMU' { It 'passes policy range and direct credentials only into provider discovery' { $operation = Initialize-PMUTestOperation $TestDrive $script:contextIdentity = Get-ManifestIdentity $operation.Plan.InstalledManifestPath - Mock Get-ProviderContext { @{ Name = 'Microsoft.PowerShell.PSResourceGet'; Version = [version]'1.2.0' } } + Mock Get-ProviderContext { @{ + Name = 'Microsoft.PowerShell.PSResourceGet' + Version = [version]'1.2.0' + } } Mock Get-ActiveModuleIdentity { $script:contextIdentity } - Mock Get-RepositorySnapshot { @{ Name = 'OwnedFeed'; Uri = 'https://example.invalid'; Trusted = $true; ApiVersion = 'V3' } } - Mock Get-PlanningInstallationState { @{ Observed = @(@{ ManifestPath = $script:contextIdentity.ManifestPath }); Installed = @{}; InstalledTree = @(); RecoveryJournal = $null } } + Mock Get-RepositorySnapshot { @{ + Name = 'OwnedFeed' + Uri = 'https://example.invalid' + Trusted = $true + ApiVersion = 'V3' + } } + Mock Get-PlanningInstallationState { @{ + Observed = @(@{ ManifestPath = $script:contextIdentity.ManifestPath }) + Installed = @{} + InstalledTree = @() + RecoveryJournal = $null + } } Mock Find-ProviderResource { @() } $credential = [pscredential]::new('fixture', [securestring]::new()) { Get-PlanningContext $operation.Record.Module 'OwnedFeed' @{ VersionRange = '[2.0.0,3.0.0)' } $credential } | Should -Throw -ErrorId 'PMU.PackageNotFound' diff --git a/tests/private/planning/Get-PlanningInstallationSnapshot.Tests.ps1 b/tests/private/planning/Get-PlanningInstallationSnapshot.Tests.ps1 new file mode 100644 index 0000000..57cc26d --- /dev/null +++ b/tests/private/planning/Get-PlanningInstallationSnapshot.Tests.ps1 @@ -0,0 +1,109 @@ +BeforeAll { + . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" +} + +Describe 'Get-PlanningInstallationSnapshot' -Tag 'PMU' { + BeforeEach { + $script:installed = [pscustomobject]@{ + ManifestPath = Join-Path $TestDrive 'Modules/FixtureModule/2.0.0/FixtureModule.psd1' + NumericVersion = '2.0.0' + } + $script:inventory = [pscustomobject]@{ + RelativePath = 'FixtureModule.psd1' + } + Mock Select-NewestInstallation { + $script:installed + } + Mock Get-FileTreeInventory { + $script:inventory + } + } + + It 'uses the