diff --git a/.github/workflows/audit-r265-sensor-safety.yml b/.github/workflows/audit-r265-sensor-safety.yml new file mode 100644 index 00000000..ac4de103 --- /dev/null +++ b/.github/workflows/audit-r265-sensor-safety.yml @@ -0,0 +1,108 @@ +name: R265 - Scoped cross-account sensor helper guard on disposable Windows +on: + push: + branches: + - release/r265-v175-sensor-crossuser-safety +permissions: + contents: read +jobs: + guest-sensor-guard: + name: "R265 no-driver guard-only cross-user test ${{ matrix.guest }}" + runs-on: ${{ matrix.system }} + timeout-minutes: 17 + strategy: + fail-fast: false + matrix: + include: + - guest: A + system: windows-2025 + - guest: B + system: windows-2022 + steps: + - name: Checkout exact unmodified public v1.7.4 ancestor + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + fetch-depth: 0 + - name: Ensure only helper/test/workflow changed; disposable admin host + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + ($env:GITHUB_REF -cne 'refs/heads/release/r265-v175-sensor-crossuser-safety' -and $env:GITHUB_REF -cne 'refs/heads/main') -or + $env:COMPUTERNAME -match '(?i)EMAD|ALI|LAPTOP'){ + throw 'R265_REJECT_NON_DISPOSABLE_HOST' + } + $base='1375d719989aa93966bae445ac63b9a415f637ad' + git merge-base --is-ancestor $base HEAD + if($LASTEXITCODE -ne 0){throw 'R265_EXACT_RELEASE_SOURCE_NOT_BASE'} + $actual=@(git diff --name-only $base HEAD) + $allowed=@( + '.github/workflows/audit-r265-sensor-safety.yml', + 'build/Test-R265-CrossUserGuard.ps1', + 'installer/TBME_Setup_Elevated_Helper.ps1' + ) + if($actual.Count -lt 14 -or $actual -cnotcontains 'installer/TBME_Setup_Elevated_Helper.ps1' -or $actual -cnotcontains 'installer/TaskbarMonitorEnhanced_Setup.cs'){ + throw 'R265_VERSIONED_PRODUCT_SOURCE_GUARD_NOT_STAGED' + } + $files=@( + @{File='installer/TBME_Setup_Elevated_Helper.ps1';SHA='84A7DC21012A63C5959447D7234B365CF926028016A81B742F891951B191E7FB'}, + @{File='build/Test-R265-CrossUserGuard.ps1';SHA='153268FBDC57F65B8AEC56ED80832F4C6EF5839A3640D0233DF2D7B4ED4606E2'} + ) + foreach($item in $files){ + $path=(Resolve-Path $item.File).Path + $str=[IO.File]::ReadAllText($path,[Text.Encoding]::UTF8) + $lf=$str.Replace(([string][char]13+[string][char]10),([string][char]10)).Replace(([string][char]13),([string][char]10)) + $sha=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($lf))) + if($sha -cne $item.SHA){throw 'R265_SOURCE_DIGEST_CHANGED'} + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($path,[ref]$tok,[ref]$err) + if(@($err).Count){throw 'R265_WINDOWS_POWERSHELL51_SYNTAX_FAILED'} + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R265_NO_ADMIN_ON_GUEST_NO_ESCALATION' + } + if(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue){ + throw 'R265_EXISTING_REAL_TASK_MUST_NOT_BE_TOUCHED' + } + if(Test-Path (Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker')){ + throw 'R265_EXISTING_REAL_BROKER_MUST_NOT_BE_TOUCHED' + } + 'R265_FRESH_VM_TEST_ONLY_SCOPE_PIN=PASS' + - name: Test actual owner SID guard on both helper modes without executing later side effects + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R265-CrossUserGuard.ps1 -HostLabel '${{ matrix.guest }}' + $p=Join-Path $env:RUNNER_TEMP 'TBME_R265_${{ matrix.guest }}/R265_CROSSUSER.json' + if(!(Test-Path $p)){throw 'R265_TEST_RECEIPT_NOT_FOUND'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Revision -cne 'R265' -or + $r.Status -cne 'PASS_OTHER_USER_SENSOR_TASK_PRESERVED_AND_SAME_OWNER_GUARD_ALLOWED' -or + $r.Guest -cne '${{ matrix.guest }}' -or + @($r.OtherUserModesRefused).Count -ne 4 -or + @($r.SameUserModesPermitted).Count -ne 4 -or + $r.VerifiedRealFullHelperEarlyRefusal -ne $true -or + $r.TestOwnedTaskDeleted -ne $true -or + $r.GuestUserAccountDeleted -ne $true -or + $r.ExactProductionHelperAfterGuardWasNotExecuted -ne $true -or + $r.NoSensorTaskTriggered -ne $true -or + $r.NoPawnIODriverOrSetupExecuted -ne $true -or + $r.OwnerPCAndPublicV174Unchanged -ne $true){ + throw 'R265_GENUINE_VM_GUARD_SCOPE_NOT_ACCEPTED' + } + 'R265_OTHER_USER_TASK_PRESERVED_BOTH_MODES_AND_SAME_OWNER_PERMITTED=PASS' + $global:LASTEXITCODE=0 + - name: Preserve disposable bounded acceptance evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R265-guard-${{ matrix.guest }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R265_${{ matrix.guest }}/R265_CROSSUSER.json diff --git a/.github/workflows/audit-r282-disposable-abrupt-recovery.yml b/.github/workflows/audit-r282-disposable-abrupt-recovery.yml new file mode 100644 index 00000000..efdd60eb --- /dev/null +++ b/.github/workflows/audit-r282-disposable-abrupt-recovery.yml @@ -0,0 +1,92 @@ +name: R282 Pinned 1.7.5 fixture and immutable 1.7.6 cold journal recovery + +on: + push: + branches: [test/r282-disposable-abrupt-update-recovery] + workflow_dispatch: + +permissions: + contents: read + +jobs: + original-release-cold-recovery: + name: Public v1.7.6 Setup / ${{ matrix.phase }} / pinned-file guest ${{ matrix.host }} + timeout-minutes: 20 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - host: A + phase: PREPARED + os: windows-2025 + - host: B + phase: COMMITTING + os: windows-2022 + steps: + - name: Checkout test-only source on exact hosted revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Verify PowerShell 5.1 parser and disposable execution contract + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC'){throw 'R282_UNSAFE_NON_DISPOSABLE_HOST'} + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R282_SOURCE_CHECKOUT_MISMATCH'} + $p=(Resolve-Path build/Test-AbruptStageRecovery-R282.ps1).Path + $tokens=$null; $errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors | ForEach-Object { Write-Output $_.Message };throw 'R282_POWERSHELL_PARSER_FAILED'} + $source=[IO.File]::ReadAllText($p) + if($source.Contains('[string]$args') -or !$source.Contains('[string]$arguments') -or + !$source.Contains('$si.Arguments=$arguments')){ + throw 'R282_BLOCK_RESERVED_POWERSHELL_AUTOMATIC_ARGS_PARAMETER' + } + if(!(Test-Path build/official-v175-managed.json)){throw 'R282_PREDECESSOR_PINNED_FIXTURE_MISSING'} + 'R282_EXACT_HOSTED_GUARDS_AND_TEST_PARSER_PASS' + + - name: Build exact resource-pinned prior fixture, kill test helper, recover with real public Setup + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-AbruptStageRecovery-R282.ps1 -HostLabel '${{ matrix.host }}' -Phase '${{ matrix.phase }}' + $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/R282_ABRUPT_RECOVERY.json' + if(!(Test-Path $receipt)){throw 'R282_DURABLE_RECEIPT_ABSENT'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_DISPOSABLE_CHILD_KILLED_THEN_OFFICIAL_INSTALLER_AUTO_RECOVERED' -or + $r.SourceBranchCommit -cne $env:GITHUB_SHA -or + $r.HostLabel -cne '${{ matrix.host }}' -or + $r.Scenario -cne '${{ matrix.phase }}' -or + $r.Official175ManagedFixtureRowsExact -ne 22 -or + $r.OriginalOfficial175InstallerFreshInstallPerformed -ne $false -or + $r.HistoricalV175FilesExtractedFromPinnedPublishedInstallerResources -ne $true -or + $r.PrivilegedSensorDriverInstallPerformedForTest -ne $false -or + $r.StagingHelperExternalProcessKilled -ne $true -or + $r.NativeSetupAutomaticJournalRecoveryAndInPlaceUpgrade -ne $true -or + $r.ExactProtectedUserDataSHA256Preserved -ne $true -or + $r.SingleCanonicalUserRoot -ne $true -or + $r.RealPhysicalMainsCut -ne $false -or + $r.ActualSetupExecutableProcessKilled -ne $false -or + $r.NoOriginalOwnerPcTouched -ne $true){throw 'R282_TYPED_DISPOSABLE_RECOVERY_NOT_ACCEPTED'} + if('${{ matrix.phase }}' -ceq 'COMMITTING' -and $r.ManagedComponentsPartiallyReplacedBeforeKill -ne 2){throw 'R282_PARTIAL_COMMIT_NOT_PROVEN'} + 'R282_VERIFIED_EXACT_OFFICIAL_176_AFTER_REAL_TEST_CHILD_KILL=PASS' + + - name: Preserve the bounded individual fail/pass receipt on hosted runner + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R282-abrupt-${{ matrix.host }}-${{ matrix.phase }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/R282_ABRUPT_RECOVERY.json + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/ARMED.json + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/ARMED.error.txt + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/R282_INSTALL_DIAGNOSTIC.json diff --git a/.github/workflows/audit-r284-candidate-restore-residue.yml b/.github/workflows/audit-r284-candidate-restore-residue.yml new file mode 100644 index 00000000..a1bcc419 --- /dev/null +++ b/.github/workflows/audit-r284-candidate-restore-residue.yml @@ -0,0 +1,135 @@ +name: R284 private v1.7.7 candidate and exact interrupted Setup recovery + +on: + push: + branches: [fix/v1.7.7-restore-residue-r284] + workflow_dispatch: + +permissions: + contents: read + +jobs: + candidate: + name: Build private unsigned v1.7.7 candidate (not a public release) + runs-on: windows-2025 + timeout-minutes: 25 + steps: + - name: Checkout exact candidate source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build hash-bound private Setup candidate + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows'){throw 'R284_BAD_HOSTED_SOURCE'} + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R284_VERSIONED_BUILD_FAILED'} + $setup='build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.7.exe' + $main='build/_out/App/TaskbarMonitorEnhanced.exe' + if(!(Test-Path $setup) -or !(Test-Path $main)){throw 'R284_PRIVATE_BINARIES_MISSING'} + $record=[ordered]@{ + Schema=1;Status='PRIVATE_NOT_A_RELEASE';Commit=$env:GITHUB_SHA + Version='1.7.7';Private=$true;NotForPublicInstallation=$true + SetupSHA256=(Get-FileHash $setup).Hash + MainSHA256=(Get-FileHash $main).Hash + } + [IO.File]::WriteAllText('build/_out/CANDIDATE.json',($record|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + 'R284_CANDIDATE_COMPILED_AND_HASH_BOUND' + - name: Upload bounded candidate for disposable tests only + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R284-PRIVATE-NOT-PUBLIC-RELEASE + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.7.exe + build/_out/App/TaskbarMonitorEnhanced.exe + + cold-recovery: + name: Recovery exact Main candidate on isolated Windows ${{ matrix.host }} + needs: candidate + timeout-minutes: 18 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Exact candidate source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Retrieve private candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R284-PRIVATE-NOT-PUBLIC-RELEASE + path: _r284_private_candidate + - name: Preflight script parser and strict disposable host + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC'){throw 'R284_UNSAFE_NON_DISPOSABLE_HOST'} + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R284_BAD_SOURCE_CHECKOUT'} + $p=(Resolve-Path build/Test-R284-RestoreResidue-NativeRecovery.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R284_PS51_SCRIPT_PARSER_INVALID'} + if(!(Test-Path build/official-v176-managed.json)){throw 'R284_PINNED_176_MISSING'} + $c=Get-Content _r284_private_candidate/CANDIDATE.json -Raw|ConvertFrom-Json + if($c.Commit -cne $env:GITHUB_SHA -or $c.Version -cne '1.7.7' -or + $c.Private -ne $true -or $c.NotForPublicInstallation -ne $true){throw 'R284_NOT_EXACT_PRIVATE_CANDIDATE'} + 'R284_GUEST_PRECONDITION_PASS' + - name: Force helper crash; prove residue recovery, and real Setup kill on B + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-R284-RestoreResidue-NativeRecovery.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + if($LASTEXITCODE -ne 0){throw 'R284_GUEST_TEST_FAILED'} + $p=Join-Path $env:RUNNER_TEMP ('R284_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R284_RECOVERY_RECEIPT.json') + if(!(Test-Path $p)){throw 'R284_GUEST_RECEIPT_MISSING'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R284' -or + $r.Status -cne 'PASS_EXACT_V177_SETUP_RETRY_AFTER_STRANDED_RESTORE' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.PreviousManifestRowsSHAAndSizeChecked -ne 22 -or + $r.StagingHelperActuallyKilled -ne $true -or + $r.StrandedUpdaterOwnedRestoreZeroByteProven -ne $true -or + $r.RecoveredAfterSecondNativeSetupRun -ne $true -or + $r.SingleCanonicalUserRootPreserved -ne $true -or + $r.ProtectedUserDataHashPreserved -ne $true -or + $r.JournalRemoved -ne $true -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.PhysicalMainsLossTriggered -ne $false){throw 'R284_RECEIPT_TYPED_PASS_FAILED'} + if('${{ matrix.host }}' -ceq 'B' -and + ($r.NativeSetupProcessActuallyKilled -ne $true -or + $r.NativeSetupManagedNoteDeletionWitnessed -ne $true)){throw 'R284_REAL_SETUP_KILL_NOT_WITNESSED'} + 'R284_GUEST_TYPED_RECOVERY_PASS' + - name: Preserve bounded guest evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R284-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R284_${{ matrix.host }}_${{ github.run_id }}/R284_RECOVERY_RECEIPT.json + ${{ runner.temp }}/R284_${{ matrix.host }}_${{ github.run_id }}/ARMED.json diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml new file mode 100644 index 00000000..d504a6a9 --- /dev/null +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -0,0 +1,1072 @@ +name: R286 private v1.7.8 candidate and exact interrupted Setup recovery + +on: + push: + branches: [feature/v1.7.8-optional-audio-r286, main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + candidate: + name: Build private unsigned v1.7.8 candidate (not a public release) + runs-on: windows-2025 + timeout-minutes: 25 + steps: + - name: Checkout exact candidate source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build hash-bound private Setup candidate + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows'){throw 'R286_BAD_HOSTED_SOURCE'} + $source=Get-Content src/TaskbarMonitorEnhanced.cs -Raw + if(!$source.Contains('EnableTemperatureWarningSound = false') -or + !$source.Contains('TemperatureWarningSoundPolicy.ShouldPlay(config)') -or + !$source.Contains('System.Media.SystemSounds.Exclamation.Play();') -or + !$source.Contains('Test warning sound')){ + throw 'R286_OPT_IN_AUDIO_FEATURE_UI_AND_POLICY_NOT_PRESENT' + } + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R286_VERSIONED_BUILD_FAILED'} + $setup='build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $main='build/_out/App/TaskbarMonitorEnhanced.exe' + if(!(Test-Path $setup) -or !(Test-Path $main)){throw 'R286_PRIVATE_BINARIES_MISSING'} + $record=[ordered]@{ + Schema=1;Status='PRIVATE_NOT_A_RELEASE';Commit=$env:GITHUB_SHA + Version='1.7.8';Private=$true;NotForPublicInstallation=$true + SetupSHA256=(Get-FileHash $setup).Hash + MainSHA256=(Get-FileHash $main).Hash + } + [IO.File]::WriteAllText('build/_out/CANDIDATE.json',($record|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + 'R286_CANDIDATE_COMPILED_AND_HASH_BOUND' + - name: Upload bounded candidate for disposable tests only + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R286-PRIVATE-NOT-PUBLIC-RELEASE + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe + build/_out/App/TaskbarMonitorEnhanced.exe + + cold-recovery: + name: Recovery exact Main candidate on isolated Windows ${{ matrix.host }} + needs: candidate + timeout-minutes: 18 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Exact candidate source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Retrieve private candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R286-PRIVATE-NOT-PUBLIC-RELEASE + path: _r284_private_candidate + - name: Preflight script parser and strict disposable host + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC'){throw 'R286_UNSAFE_NON_DISPOSABLE_HOST'} + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R286_BAD_SOURCE_CHECKOUT'} + $p=(Resolve-Path build/Test-R286-RestoreResidue-NativeRecovery.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R286_PS51_SCRIPT_PARSER_INVALID'} + if(!(Test-Path build/official-v177-managed.json)){throw 'R286_PINNED_176_MISSING'} + $c=Get-Content _r284_private_candidate/CANDIDATE.json -Raw|ConvertFrom-Json + if($c.Commit -cne $env:GITHUB_SHA -or $c.Version -cne '1.7.8' -or + $c.Private -ne $true -or $c.NotForPublicInstallation -ne $true){throw 'R286_NOT_EXACT_PRIVATE_CANDIDATE'} + 'R286_GUEST_PRECONDITION_PASS' + - name: Force helper crash; prove residue recovery, and real Setup kill on B + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-R286-RestoreResidue-NativeRecovery.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + if($LASTEXITCODE -ne 0){throw 'R286_GUEST_TEST_FAILED'} + $p=Join-Path $env:RUNNER_TEMP ('R286_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R286_RECOVERY_RECEIPT.json') + if(!(Test-Path $p)){throw 'R286_GUEST_RECEIPT_MISSING'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R286' -or + $r.Status -cne 'PASS_EXACT_V178_SETUP_RETRY_AFTER_STRANDED_RESTORE' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.PreviousManifestRowsSHAAndSizeChecked -ne 22 -or + $r.StagingHelperActuallyKilled -ne $true -or + $r.StrandedUpdaterOwnedRestoreZeroByteProven -ne $true -or + $r.RecoveredAfterSecondNativeSetupRun -ne $true -or + $r.SingleCanonicalUserRootPreserved -ne $true -or + $r.ProtectedUserDataHashPreserved -ne $true -or + $r.JournalRemoved -ne $true -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.PhysicalMainsLossTriggered -ne $false){throw 'R286_RECEIPT_TYPED_PASS_FAILED'} + if('${{ matrix.host }}' -ceq 'B' -and + ($r.NativeSetupProcessActuallyKilled -ne $true -or + $r.NativeSetupManagedNoteDeletionWitnessed -ne $true)){throw 'R286_REAL_SETUP_KILL_NOT_WITNESSED'} + 'R286_GUEST_TYPED_RECOVERY_PASS' + - name: R290 reject native child junctions before any recovery mutation + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R290-ReparseRecovery-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R290_NATIVE_TEST_PARSER_INVALID'} + ./build/Test-R290-ReparseRecovery-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + # The script throws on failure; LASTEXITCODE can be stale from a prior native Setup. + # Validate its typed native receipt instead of mistaking that stale code for failure. + $p=Join-Path $env:RUNNER_TEMP ('R290_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R290_REPARSE_RECEIPT.json') + if(!(Test-Path $p)){throw 'R290_GUEST_RECEIPT_MISSING'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R290' -or + $r.Status -cne 'PASS_NATIVE_UPGRADE_CHILD_REPARSE_FAIL_CLOSED' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.NativeWindowsJunctionCreated -ne $true -or + $r.NormalPathAcceptance -ne $true -or + $r.ParentTraversalRejected -ne $true -or + $r.LiveChildJunctionRejected -ne $true -or + $r.StageOldJunctionRejected -ne $true -or + $r.RecoverPreMutationRejected -ne $true -or + $r.NoExternalCanaryMutation -ne $true -or + $r.InstalledMainAndConfigUnchanged -ne $true -or + $r.NoStageOrLinkResidue -ne $true){throw 'R290_TYPED_NATIVE_REPARSE_RECEIPT_INVALID'} + 'R290_NATIVE_CHILD_REPARSE_GUARDS_PASS' + - name: R291 reject untrusted paths before elevated sensor helper writes + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R291-SensorHelperBoundary-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R291_PS51_NATIVE_HARNESS_PARSE_INVALID'} + ./build/Test-R291-SensorHelperBoundary-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R291_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R291_BOUNDARY_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R291_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R291' -or + $r.Status -cne 'PASS_PRIVILEGED_HELPER_BOUNDARY_NATIVE' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ProtectedServicesOrUACInvoked -ne $false -or + $r.RealWindowsJunctionUsed -ne $true -or + $r.PositiveAndNegativePathChecksPassed -ne $true -or + $r.DanglingJunctionRejected -ne $true -or + $r.EmbeddedHelperSourceMatched -ne $true -or + $r.InstalledMainUnchanged -ne $true -or + $r.CheckCount -lt 14 -or + @($r.Checks|Where-Object {$_ -ceq 'DOWNLOADS_ROOT_WITH_VALID_NAME_REFUSED'}).Count -ne 1 -or + @($r.Checks|Where-Object {$_ -ceq 'NESTED_TEMP_WITH_VALID_NAME_REFUSED'}).Count -ne 1){throw 'R291_TYPED_BOUNDARY_RECEIPT_INVALID'} + 'R291_NATIVE_ELEVATED_SENSOR_BOUNDARY_GUARDS_PASS' + - name: R293 execute trusted inline Helper, never rogue writable Temp script + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R293-InMemorySensorHelper-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R293_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R293-InMemorySensorHelper-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R293_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R293_INMEMORY_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R293_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R293' -or + $r.Status -cne 'PASS_NO_TEMP_SCRIPT_ELEVATION_INLINE_TRUSTED_SOURCE' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrProtectedServiceCalled -ne $false -or + $r.ExactTrustedHelperSource -ne $true -or $r.MaliciousTempScriptIgnored -ne $true -or + $r.RealNonElevatedInMemoryPowerShellCalled -ne $true -or + $r.InvalidAppRootRejectedBeforeWrite -ne $true -or + $r.InstalledMainUnchanged -ne $true -or + $r.BoundedInMemoryCommandLength -lt 1000 -or $r.BoundedInMemoryCommandLength -gt 30000 -or + @($r.Checks).Count -lt 9 -or + @($r.Checks|Where-Object {$_ -ceq 'QUOTED_HOSTILE_USER_INPUT_ENCODED_AS_DATA'}).Count -ne 1 -or + @($r.Checks|Where-Object {$_ -ceq 'COMMAND_LINE_INJECTION_SENTINEL_NOT_EXECUTED'}).Count -ne 1){throw 'R293_NATIVE_INMEMORY_RECEIPT_REJECTED'} + 'R293_TRUSTED_INMEMORY_HELPER_NEGATIVE=PASS' + - name: R294 verify locked SHA-pinned executable source, tamper and sharing rejection + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R294-PayloadProvenance-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object {$_.Message};throw 'R294_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R294-PayloadProvenance-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R294_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R294_PROVENANCE_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R294_PROVENANCE_RECEIPT_NOT_CREATED'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R294' -or + $r.Status -cne 'PASS_SHA_PINNED_EXCLUSIVE_SINGLE_READ_SENSOR_BINARIES' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrServiceMutation -ne $false -or + $r.BothTamperedInputsRejected -ne $true -or $r.ExclusiveLockProved -ne $true -or + $r.VerifiedBuffersInstalledToSandboxOnly -ne $true -or $r.InstalledMainUnchanged -ne $true -or + $r.ExpectedBrokerSHA256 -cnotmatch '^[A-F0-9]{64}$' -or + $r.ExpectedSupervisorSHA256 -cnotmatch '^[A-F0-9]{64}$' -or + @($r.Checks).Count -lt 10){throw 'R294_TYPED_NATIVE_PROVENANCE_RECEIPT_FAILED'} + 'R294_SHA_PINNED_EXCLUSIVE_VERIFIED_BYTES_PASS' + - name: R295 verify PE-pinned ZIP dependency bytes and hostile archive rejection + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R295-TrustedBackendZip-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R295_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R295-TrustedBackendZip-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R295_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R295_ZIP_PROVENANCE_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R295_NATIVE_RECEIPT_NOT_FOUND'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R295' -or + $r.Status -cne 'PASS_PE_PINNED_ZIP_BYTE_ONLY_BACKEND_PROVENANCE' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.ElevatedUACOrProtectedServiceUsed -ne $false -or + $r.EmbeddedArchiveSHA256 -cnotmatch '^[A-F0-9]{64}$' -or + $r.EntryCount -ne 43 -or $r.UncompressedBytes -ne 12203773 -or + $r.TamperedArchiveRefused -ne $true -or + $r.TraversalAndDuplicateAndLinkRefused -ne $true -or + $r.ExclusiveSourceLockVerified -ne $true -or + $r.VerifiedBytesOnlyCopiedToSandbox -ne $true -or + $r.InstalledMainUnchanged -ne $true -or @($r.Checks).Count -lt 11){throw 'R295_TYPED_ZIP_RECEIPT_INVALID'} + 'R295_EXACT_ZIP_TRAVERSAL_TAMPER_DUPLICATE_LINK_REFUSAL_PASS' + - name: R296 prove old sensor rollback stays in private process memory + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R296-RollbackMemory-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R296_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R296-RollbackMemory-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R296_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R296_MEMORY_ROLLBACK_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R296_MEMORY_ROLLBACK_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R296' -or + $r.Status -cne 'PASS_MEMORY_ONLY_ROLLBACK_NO_USER_TEMP_TRUST' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrProtectedServiceCalled -ne $false -or + $r.PrivateSnapshotFiles -ne 3 -or $r.ForgedUserTempIgnored -ne $true -or + $r.TwoJunctionDirectionsRejected -ne $true -or + $r.CorruptedOriginalRestoredFromMemory -ne $true -or + $r.NoRollbackDiskFiles -ne $true -or $r.InstalledMainUnchanged -ne $true -or + @($r.Checks).Count -lt 12){throw 'R296_TYPED_GUEST_MEMORY_RECEIPT_INVALID'} + 'R296_ROLLBACK_IN_MEMORY_TAMPER_AND_REPARSE=PASS' + - name: R297 refuse forged PawnIO automatic elevation from writable Temp + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R297-NoPawnAutoElevation-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R297_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R297-NoPawnAutoElevation-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R297_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R297_PAWN_REFUSAL_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R297_TYPED_GUEST_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R297' -or + $r.Status -cne 'PASS_NO_UNTRUSTED_PAWNIO_AUTORUN' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrDriverServiceMutation -ne $false -or + $r.FakeTempPawnInstallerIgnored -ne $true -or + $r.MissingDriverRequiresOfficialExternalConsent -ne $true -or + $r.ExistingCompatiblePawnIORetained -ne $true -or $r.MainUnchanged -ne $true -or + @($r.Checks).Count -lt 10){throw 'R297_TYPED_PAWNIO_REFUSAL_RECEIPT_INVALID'} + 'R297_FORGED_TEMP_PAWNIO_ELEVATION_REFUSED=PASS' + - name: R298 validate pinned Windows PowerShell executable for runas + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R298-PinnedPowerShellHost-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R298_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R298-PinnedPowerShellHost-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R298_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R298_TYPED_HOST_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R298' -or + $r.Status -cne 'PASS_ABSOLUTE_SYSTEM_POWERSHELL_RUNAS_HOST' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrProtectedServiceInvoked -ne $false -or + $r.AbsoluteHostMatchesSystemDirectory -ne $true -or + $r.FakeCwdAndPathExecutableIgnored -ne $true -or $r.BothModesPinned -ne $true -or + $r.NoUntrustedScriptFile -ne $true -or $r.InstalledMainUnchanged -ne $true -or + @($r.Checks).Count -lt 12){throw 'R298_NATIVE_PINNED_HOST_RECEIPT_INVALID'} + 'R298_SYSTEM_POWERSHELL_HOST_PINNED=PASS' + - name: R299 reject hostile upgrade journal and remove known files without recursive delete + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R299-NonrecursiveStageCleanup-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R299_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R299-NonrecursiveStageCleanup-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R299_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R299_NONRECURSIVE_CLEANUP_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R299_TYPED_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R299' -or + $r.Status -cne 'PASS_NONRECURSIVE_ALLOWLISTED_STAGE_CLEANUP' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or + $r.UnknownFilePreserved -ne $true -or $r.UnknownEmptyDirPreserved -ne $true -or + $r.NativeJunctionRefused -ne $true -or $r.ExternalCanaryPreserved -ne $true -or + $r.PositiveNestedStageCleaned -ne $true -or $r.NoStageResidue -ne $true -or + $r.InstalledUserFilesUnchanged -ne $true -or + @($r.Checks).Count -lt 6){throw 'R299_NATIVE_RESULT_NOT_ACCEPTED'} + 'R299_BOUNDARY_AND_NONRECURSIVE_CLEANUP=PASS' + - name: R300 resume safely retired upgrade journal after interruption + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R300-RetiredJournalRecovery-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R300_PS51_TEST_PARSE_FAILED'} + ./build/Test-R300-RetiredJournalRecovery-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R300_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R300_RETIRED_JOURNAL_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R300_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R300' -or + $r.Status -cne 'PASS_RETIRED_STAGE_RESTART_AND_HOSTILE_REFUSAL' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or $r.RealProcessKilled -ne $false -or + $r.SimulatedPostMoveStop -ne $true -or + $r.SameParentDirectoryRename -ne $true -or + $r.PostMoveCleanupRetried -ne $true -or $r.InterruptedMetadataCleanupRetried -ne $true -or + $r.UnknownFileRefused -ne $true -or $r.ReparseRefused -ne $true -or + $r.UnknownOwnerRefused -ne $true -or + $r.InstalledMainConfigStateUnchanged -ne $true -or + $r.NoRetiredOrActiveStageResidue -ne $true -or + @($r.Checks).Count -lt 12){throw 'R300_TYPED_RESULT_INVALID'} + 'R300_NATIVE_RETIRED_JOURNAL_RECOVERY=PASS' + - name: R301 replay rollback cleanup after real disposable worker termination + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R301-RolledBackRestart-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R301_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R301-RolledBackRestart-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R301_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R301_ROLLBACK_RETRY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R301_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R301' -or + $r.Status -cne 'PASS_ROLLED_BACK_JOURNAL_RETRY_REAL_WORKER_TERMINATION' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or + $r.ActualOriginalSetupProcessKilled -ne $false -or + $r.ActualDisposableProofWorkerKilled -ne $true -or + $r.ProductionWritePhaseExecutedInChild -ne $true -or + $r.FreshRecoveryProcessSucceeded -ne $true -or + $r.MissingOldSnapshotsAcceptedAfterVerifiedRollback -ne $true -or + $r.TamperedPriorFileDigestRejected -ne $true -or + $r.RegistryVersionMismatchRejected -ne $true -or + $r.UnsupportedVersionRejected -ne $true -or + $r.OriginalMainAndSettingsUnchanged -ne $true -or + $r.NoJournalResidue -ne $true -or + @($r.Checks).Count -lt 8){throw 'R301_TYPED_NATIVE_RECEIPT_INVALID'} + 'R301_REAL_TEST_WORKER_KILL_AND_RESTART_RECOVERY_PASS' + - name: R302 actually terminate original Setup during stage cleanup and resume safely + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R302_NATIVE_PS51_TEST_PARSE_FAILED'} + ./build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R302_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R302_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R302' -or + $r.Status -cne 'PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.UnmodifiedCandidateSetupExecuted -ne $true -or + $r.NativeExactSetupSuspendedBoundBeforeResume -ne $true -or + $r.ActualSetupProcessTerminated -ne $true -or + $r.RecoveredBySecondActualSetupProcess -ne $true -or + $r.MainSetupConfigStateAndRegistryPreserved -ne $true -or + $r.NoStageOrRetiredResidue -ne $true -or + $r.ActualSetupPid -le 0 -or $r.SecondSetupPid -le 0 -or + $r.ActualSetupPid -eq $r.SecondSetupPid -or + $r.DeletedStageFileTrigger -cnotmatch '\.(exe|cs|md|json|config|ico)$' -or + $r.NativeWatcherOrFileSystemWatcherOrigin -cnotin @( + 'NATIVE_KNOWN_STAGE_LEAF_DELETION','FILESYSTEMWATCHER_KNOWN_STAGE_LEAF_DELETION') -or + ($r.FileDeletionNotifications + $r.NativeKnownDeletedLeafObservations) -lt 1 -or $r.JournalFilesAfterKill -lt 1 -or + @($r.Tests).Count -lt 5){throw 'R302_NATIVE_ACCEPTANCE_FAILED'} + 'R302_EXACT_NATIVE_SETUP_PROCESS_KILL_AND_RECOVERY_PASS' + - name: R303 reject foreign protected broker owners and permit same-user crash repair + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R303-ProtectedSensorOwner-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object {$_.Message};throw 'R303_NATIVE_PS51_TEST_PARSE_INVALID'} + ./build/Test-R303-ProtectedSensorOwner-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R303_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R303_PROTECTED_SENSOR_OWNER_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R303_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R303' -or + $r.Status -cne 'PASS_PROTECTED_SENSOR_OWNER_MARKER_REPAIR_GUARD' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.PEEmbeddedSourceExact -ne $true -or + $r.RealDisposableWriterKilledAfterMarkerFlush -ne $true -or + $r.ActualElevatedHelperProcessKilled -ne $false -or + $r.ActualProtectedServicesChanged -ne $false -or + $r.MissingOrForeignOwnerRefused -ne $true -or + $r.SameOwnerWithoutTaskAccepted -ne $true -or + $r.ActualJunctionRefused -ne $true -or + $r.MainAndUserDataPreserved -ne $true -or + @($r.Checks).Count -lt 14){throw 'R303_NATIVE_OWNER_MARKER_RECEIPT_INVALID'} + 'R303_SAME_ACCOUNT_PARTIAL_PROTECTED_SENSOR_REPAIR_ALLOWED=PASS' + - name: R304 verify long inline PowerShell command without writable scripts + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R304-LongInlinePowerShell-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R304_PS51_PROBE_SYNTAX_INVALID'} + ./build/Test-R304-LongInlinePowerShell-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $path=Join-Path $env:RUNNER_TEMP ('R304_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json') + if(!(Test-Path -LiteralPath $path)){throw 'R304_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R304' -or + $r.Status -cne 'PASS_LONG_PROFILE_INMEMORY_POWERSHELL_FALLBACK' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.NoUAC -ne $true -or + ($r.ShortEncodedCommandUsed -ne $true -and $r.ShortInlineTransportVerified -ne $true) -or + $r.LongProfileFallbackCommandExecuted -ne $true -or + $r.NoUserWritableScriptExecuted -ne $true -or + $r.HostileInputRemainedEncoded -ne $true -or + $r.InstalledUserFilesUnchanged -ne $true -or + $r.ShortCommandLength -gt 32080 -or + $r.LongFallbackCommandLength -gt 30000 -or + @($r.Checks).Count -lt 9){throw 'R304_NATIVE_GATES_NOT_MET'} + 'R304_NATIVE_SHORT_LONG_HOSTILE_TRANSPORT=PASS' + - name: R305 require actual privileged process token SID to match target user + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R305-EffectiveUacTokenSid-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R305_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R305-EffectiveUacTokenSid-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R305_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R305_ELEVATED_TOKEN_SID_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R305_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R305' -or + $r.Status -cne 'PASS_EFFECTIVE_PROCESS_TOKEN_SID_BOUND_TO_REQUESTED_USER' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualUACOrProtectedServicesInvoked -ne $false -or + $r.PrivateEmbeddedPEHelperExact -ne $true -or + $r.SameUserIdentityAccepted -ne $true -or + $r.CrossAccountSyntheticSidsRejected -ne $true -or + $r.RejectedBeforeAnyLogOrServiceWrite -ne $true -or + $r.MainConfigStateAndMarkerUnchanged -ne $true -or + @($r.Checks).Count -lt 11){throw 'R305_TYPED_NATIVE_TOKEN_SID_ACCEPTANCE_FAILED'} + 'R305_EFFECTIVE_TOKEN_SID_PREWRITE_BOUNDARY=PASS' + - name: R306 synthetic protected rollback journal crash and recovery model + shell: powershell + run: | + $ErrorActionPreference='Stop' + $src=(Resolve-Path build/Test-R306-DurableRollbackModel.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($src,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R306_PS51_MODEL_PARSER_INVALID'} + $prior=Join-Path $env:RUNNER_TEMP ('R305_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R305_ELEVATED_TOKEN_SID_RECEIPT.json') + $p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json + if($p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne '${{ matrix.host }}' -or + $p.Status -cne 'PASS_EFFECTIVE_PROCESS_TOKEN_SID_BOUND_TO_REQUESTED_USER'){ + throw 'R306_PRIOR_NATIVE_SID_EVIDENCE_NOT_EXACT' + } + $main=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced/TaskbarMonitorEnhanced.exe' + $before=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe' + $lines=@(& $native -NoLogo -NoProfile -NonInteractive -File $src -Mode Driver) + if($LASTEXITCODE -ne 0){throw 'R306_MODEL_PROCESS_FAILED'} + $witness=($lines|Where-Object {$_ -like 'R306_PROTOTYPE_PASS Receipt=*'}|Select-Object -Last 1) + if(!$witness){throw 'R306_MODEL_POSITIVE_WITNESS_MISSING'} + $origin=[string]$witness.Substring('R306_PROTOTYPE_PASS Receipt='.Length).Trim() + $actualFile=[IO.Path]::GetFullPath($origin) + $actualDir=[IO.Path]::GetDirectoryName($actualFile) + $actualParent=[IO.Path]::GetDirectoryName($actualDir) + if([IO.Path]::GetFileName($actualFile) -cne 'R306_RECEIPT.json' -or + [IO.Path]::GetFileName($actualDir) -cnotmatch '^R306_DISPOSABLE_[0-9a-f]{32}$'){ + throw 'R306_RESULT_SYNTHETIC_LAYOUT_UNTRUSTED' + } + $allowed=@($env:TEMP,$env:TMP,$env:RUNNER_TEMP,[IO.Path]::GetTempPath()) + $valid=@($allowed|Where-Object {$_ -and + [string]::Equals([IO.Path]::GetFullPath($_).TrimEnd([IO.Path]::DirectorySeparatorChar), + $actualParent,[StringComparison]::OrdinalIgnoreCase)}) + if($valid.Count -lt 1){ + throw ('R306_RESULT_OUTSIDE_SYNTHETIC_TEMP parent='+$actualParent+' allowed='+($allowed -join ';')) + } + $r=Get-Content -LiteralPath $origin -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R306-PROTOTYPE' -or + $r.Commit -cne $env:GITHUB_SHA -or + $r.Status -cne 'PASS_SYNTHETIC_DURABLE_SNAPSHOT_KILL_RESTORE_AND_TAMPER_REFUSAL' -or + $r.OriginalProtectedServicesTouched -ne $false -or $r.UACInvoked -ne $false -or + $r.ProgramFilesWritten -ne $false -or $r.ActualProofWorkerKilled -ne $true -or + $r.SameUserFreshProcessRestore -ne $true -or + $r.CrossSidRefusedBeforeWrite -ne $true -or $r.MissingReadyMarkerRefused -ne $true -or + $r.PreWriteSnapshotHashValidation -ne $true -or + $r.ActualSensorHelperKilled -ne $false -or + $r.ExternalCanaryPreserved -ne $true){ + throw 'R306_SYNTHETIC_RECEIPT_NOT_ACCEPTED' + } + $dest=Join-Path $env:RUNNER_TEMP ('R306_${{ matrix.host }}_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $dest){throw 'R306_TYPED_RECEIPT_DEST_EXISTS'} + [void][IO.Directory]::CreateDirectory($dest) + $copy=Join-Path $dest 'R306_ROLLBACK_MODEL_RECEIPT.json' + [IO.File]::Copy($origin,$copy,$false) + if((Get-FileHash -LiteralPath $origin -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $copy -Algorithm SHA256).Hash){ + throw 'R306_RAW_RECEIPT_COPY_SHA_MISMATCH' + } + if((Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash -cne $before){ + throw 'R306_GUEST_INSTALLED_MAIN_CHANGED' + } + 'R306_SYNTHETIC_JOURNAL_ACTUAL_WORKER_KILL_PASS_NOT_PRODUCTION_ACCEPTANCE' + - name: R307 verify owner-protected durable journal DACL with hostile Windows paths + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R307-ProtectedJournalAcl-Native.ps1).Path + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$t,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R307_WINDOWS_PS51_TEST_PARSE_FAILED'} + $old=Join-Path $env:RUNNER_TEMP ('R306_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R306_ROLLBACK_MODEL_RECEIPT.json') + $prior=Get-Content -LiteralPath $old -Raw|ConvertFrom-Json + if($prior.Commit -cne $env:GITHUB_SHA -or + $prior.Status -cne 'PASS_SYNTHETIC_DURABLE_SNAPSHOT_KILL_RESTORE_AND_TAMPER_REFUSAL' -or + $prior.ActualProofWorkerKilled -ne $true){throw 'R307_PRIOR_MODEL_EVIDENCE_NOT_EXACT'} + $main=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced/TaskbarMonitorEnhanced.exe' + $before=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + ./build/Test-R307-ProtectedJournalAcl-Native.ps1 -HostLabel '${{ matrix.host }}' + $path=Join-Path $env:RUNNER_TEMP ('R307_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R307_PROTECTED_JOURNAL_ACL_RECEIPT.json') + if(!(Test-Path -LiteralPath $path)){throw 'R307_TYPED_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R307-PROTOTYPE' -or + $r.Status -cne 'PASS_PROTECTED_JOURNAL_ACL_TRUST_BOUNDARY_SYNTHETIC' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OwnerComputerTouched -ne $false -or $r.ActualProtectedSensorTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or $r.ExplicitProtectedDaclCreated -ne $true -or + $r.OwnerTrustedAdministrators -ne $true -or $r.SystemAdminAccessVerified -ne $true -or + $r.ManifestFileOwnerTrusted -ne $true -or + $r.UserWritableRootRejected -ne $true -or $r.NativeJunctionRejected -ne $true -or + $r.FlushedManifestHashMatched -ne $true -or $r.OutsideCanaryPreserved -ne $true -or + @($r.Checks).Count -ne 7){throw 'R307_NATIVE_ACL_PROOF_NOT_ACCEPTED'} + if((Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash -cne $before){ + throw 'R307_INSTALLED_DISPOSABLE_GUEST_MAIN_CHANGED' + } + 'R307_SYNTHETIC_PROTECTED_JOURNAL_ACL_PASS_NOT_PRODUCTION_RECOVERY' + - name: R308 validate production protected journal prewrite functions on disposable Windows + shell: powershell + run: | + $ErrorActionPreference='Stop' + $script=(Resolve-Path build/Test-R308-ProtectedPrewrite-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($script,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R308_PS51_PARSER_INVALID'} + ./build/Test-R308-ProtectedPrewrite-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $path=Join-Path $env:RUNNER_TEMP ('R308_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R308_JOURNAL_PREWRITE_RECEIPT.json') + if(!(Test-Path -LiteralPath $path)){throw 'R308_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R308' -or + $r.Status -cne 'PASS_PRODUCTION_JOURNAL_PREWRITE_FUNCTIONS_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or $r.ActualProgramFilesWritten -ne $false -or + $r.ActualScheduledTaskModified -ne $false -or $r.ActualUACInvoked -ne $false -or + $r.ActualProtectedSensorHelperKilled -ne $false -or + $r.ProductionFunctionsExtractedFromExactPE -ne $true -or + $r.JournalReadyPublishedPreMutation -ne $true -or + $r.StaleJournalFailsClosed -ne $true -or + $r.SIDAndHashAndReparseFailClosed -ne $true -or + $r.ExternalUserDataUnchanged -ne $true -or + $r.TerminalCleanupAndColdRecoveryImplemented -ne $false -or + @($r.Checks).Count -ne 15){throw 'R308_NATIVE_JOURNAL_PREWRITE_PROOF_NOT_ACCEPTED'} + 'R308_PREWRITE_ONLY_PASS_PRODUCTION_COLD_RECOVERY_STILL_OPEN' + + - name: R309 prove terminal journal and cold file replay with killed proof worker + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R309-TerminalReplay-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R309_WINPS51_TEST_PARSER_INVALID'} + ./build/Test-R309-TerminalReplay-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R309_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R309_TERMINAL_REPLAY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R309_NATIVE_PROOF_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R309' -or + $r.Status -cne 'PASS_SYNTHETIC_TERMINAL_AND_REAL_WORKER_COLD_FILE_REPLAY' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.ActualOriginalElevatedHelperKilled -ne $false -or + $r.ActualProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or $r.UACInvoked -ne $false -or + $r.OwnerComputerTouched -ne $false -or $r.ActualSyntheticWorkerKilled -ne $true -or + $r.FreshPowerShellRecoveredOldFiles -ne $true -or + $r.ProductionPEReplayFunctionsVerified -ne $true -or + $r.TerminalCommittedAndRolledBackVerified -ne $true -or + $r.PinnedTaskXmlRecoveredAsDataNotRegistered -ne $true -or + $r.TamperAndForeignSidFailedClosed -ne $true -or + $r.UnknownFileAndJunctionFailedClosed -ne $true -or + $r.OfficialInstalledMainAndUserConfigUnchanged -ne $true -or + $r.WorkerPid -le 0 -or $r.RecoveryPid -le 0 -or + $r.WorkerPid -eq $r.RecoveryPid -or + @($r.Checks).Count -ne 16){throw 'R309_TYPED_PRODUCTION_FUNCTIONS_PROOF_FAILED'} + 'R309_SYNTHETIC_TERMINAL_AND_REPLAY_PASS_NO_UAC_OR_TASK_RESTORE' + - name: R310 kill proof worker inside retired journal cleanup and resume safely + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R310-RetiredJournal-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R310_PS51_PROOF_PARSER_INVALID'} + ./build/Test-R310-RetiredJournal-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R310_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R310_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R310' -or + $r.Status -cne 'PASS_SYNTHETIC_RETIRED_TERMINAL_PARTIAL_CLEANUP_RESTART' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACOrProtectedTaskModified -ne $false -or + $r.ActualOriginalElevatedHelperKilled -ne $false -or + $r.RealProofWorkerKilledDuringCleanup -ne $true -or + $r.FreshProcessFinishedRetiredCleanup -ne $true -or + $r.NonrecursiveKnownMemberDeletion -ne $true -or + $r.UnknownFilesAndJunctionFailClosed -ne $true -or + $r.ForeignSIDAndPreparedPhaseRefused -ne $true -or + $r.TerminatedJournalEventuallyRemoved -ne $true -or + $r.UserDataAndCanaryUnchanged -ne $true -or + $r.WorkerPid -le 0 -or $r.RestartPid -le 0 -or + $r.WorkerPid -eq $r.RestartPid -or + @($r.Checks).Count -ne 15){throw 'R310_TYPED_NATIVE_JOURNAL_CLEANUP_FAILED'} + 'R310_SYNTHETIC_RETIRED_NONRECURSIVE_CRASH_CLEANUP=PASS' + - name: R311 verify successful-only protected journal terminal hooks and fail-closed pending + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R311-HealthyTerminalHooks-Native.ps1).Path + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$t,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R311_PS51_TEST_SYNTAX_INVALID'} + ./build/Test-R311-HealthyTerminalHooks-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R311_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R311_RAW_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R311' -or + $r.Status -cne 'PASS_SYNTHETIC_NORMAL_HEALTHY_TERMINAL_HOOKS' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProgramFilesWritten -ne $false -or + $r.ActualUACPerformed -ne $false -or + $r.ActualScheduledTaskModified -ne $false -or + $r.OriginalElevatedHelperKilled -ne $false -or + $r.ExactPeEntrypointHookOrderValidated -ne $true -or + $r.HealthyCommittedAndRetired -ne $true -or + $r.UnhealthyAndPreparedPreserved -ne $true -or + $r.ColdTerminalCleanupCompleted -ne $true -or + $r.WrongSidReparseUnknownRefused -ne $true -or + $r.ActualTaskXmlColdRestoreDone -ne $false -or + $r.InstalledOwnerDataUnchanged -ne $true -or + @($r.Checks).Count -ne 18){throw 'R311_NATIVE_ACCEPTANCE_GATES_UNMET'} + 'R311_HEALTHY_COMMIT_AND_PENDING_PREPARED_FAIL_CLOSED=PASS' + - name: R312 reject foreign or executable-task XML before any cold ScheduledTask restore + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R312-PinnedTaskXml-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R312_PS51_NATIVE_TEST_SYNTAX_INVALID'} + ./build/Test-R312-PinnedTaskXml-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R312_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R312_TRUSTED_TASK_XML_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R312_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R312' -or + $r.Status -cne 'PASS_EXACT_PRODUCTION_TASK_XML_POLICY_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.ActualScheduledTaskModified -ne $false -or + $r.ExactPEPolicyFunctionsLoaded -ne $true -or + $r.LegitimateTaskXmlAccepted -ne $true -or + $r.CommandPrincipalTriggerAndXmlInjectionRejected -ne $true -or + $r.InstalledOwnerDataUnchanged -ne $true -or + $r.ProductionColdTaskRegistrationIntegrated -ne $false -or + @($r.Checks).Count -ne 18){throw 'R312_NATIVE_TASK_XML_POLICY_GATES_UNMET'} + 'R312_PINNED_TASK_XML_POLICY_SYNTHETIC=PASS_NO_TASK_REGISTRATION' + - name: R313 register and remove only unique disabled disposable ScheduledTask XML + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R313-DisposableScheduledTask-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R313_PS51_TEST_PARSER_INVALID'} + ./build/Test-R313-DisposableScheduledTask-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R313_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R313_TASK_REGISTRATION_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R313_RAW_SCHEDULER_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R313' -or + $r.Status -cne 'PASS_EPHEMERAL_DISABLED_SCHEDULED_TASK_REGISTER_EXPORT_UNREGISTER' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProtectedSensorTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACPerformed -ne $false -or + $r.RegisteredOnlyUniqueDisposableTask -ne $true -or + $r.NeverEnabledOrExecuted -ne $true -or + $r.NoForceRegistration -ne $true -or + $r.ColdRegistrationPerformedOnDisposableTask -ne $true -or + $r.ExactPrincipalAndCommandReadback -ne $true -or + $r.DisposableTaskAbsentAfterTest -ne $true -or + $r.UserFilesUnchanged -ne $true -or + $r.ProductionPriorTaskColdRestoreIntegrated -ne $false -or + [string]::IsNullOrEmpty($r.ExportXmlSHA256) -or + @($r.Checks).Count -ne 12){throw 'R313_TYPED_NATIVE_SCHEDULER_GATES_NOT_MET'} + 'R313_DISABLED_EPHEMERAL_TASK_XML_REAL_SCHEDULER_ROUNDTRIP_PASS' + - name: R314 kill worker after task loss and restore disabled task with exact previous files + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R314-ColdTaskReplay-Native.ps1).Path + $tok=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R314_NATIVE_WINPS51_TEST_PARSER_INVALID'} + ./build/Test-R314-ColdTaskReplay-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R314_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R314_TASK_COLD_RESTORE_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R314_EXACT_GUEST_PROOF_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R314' -or + $r.Status -cne 'PASS_DISPOSABLE_TASK_AND_FILES_COLD_RESTORE_AFTER_KILLED_WORKER' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.OriginalProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.OriginalElevatedHelperKilled -ne $false -or + $r.RealSyntheticWorkerKilled -ne $true -or + $r.FreshProcessRestoredActualDisposableScheduledTask -ne $true -or + $r.FilesRestoredByteExact -ne $true -or + $r.TaskDisabledNeverExecuted -ne $true -or + $r.JournalTerminalAndRetiredCleanupComplete -ne $true -or + $r.ForeignSidTamperAndUnknownFileRejectedPrewrite -ne $true -or + $r.OriginalAppDataUnchanged -ne $true -or + $r.WorkerPid -le 0 -or $r.RecoveryPid -le 0 -or + $r.WorkerPid -eq $r.RecoveryPid -or + @($r.Checks).Count -ne 14){throw 'R314_COLD_TASK_AND_FILES_PROOF_GATES_UNMET'} + 'R314_REAL_DISPOSABLE_SCHEDULER_COLD_RESTORE_PASS_NO_UAC' + - name: R315 invoke production PREPARED cold entrypoint after real disposable task loss + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R315-ProductionColdEntrypoint-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R315_PS51_PROOF_SYNTAX_INVALID'} + ./build/Test-R315-ProductionColdEntrypoint-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R315_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R315_TASK_COLD_RESTORE_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R315_NATIVE_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R315' -or + $r.Status -cne 'PASS_PREPARED_PRODUCTION_ENTRYPOINT_COLD_REPLAY_DISPOSABLE_TASK' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.OriginalProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.OriginalElevatedHelperKilled -ne $false -or + $r.RealSyntheticWorkerKilled -ne $true -or + $r.FreshProcessRestoredActualDisposableScheduledTask -ne $true -or + $r.FilesRestoredByteExact -ne $true -or + $r.TaskDisabledNeverExecuted -ne $true -or + $r.JournalTerminalAndRetiredCleanupComplete -ne $true -or + $r.ForeignSidTamperAndUnknownFileRejectedPrewrite -ne $true -or + $r.OriginalAppDataUnchanged -ne $true -or + $r.ProductionColdEntrypointInvoked -ne $true -or + $r.WorkerPid -le 0 -or $r.RecoveryPid -le 0 -or + $r.WorkerPid -eq $r.RecoveryPid -or + @($r.Checks).Count -ne 15){throw 'R315_NATIVE_ENTRYPOINT_RECOVERY_GATES_NOT_MET'} + 'R315_EXACT_PE_PREPARED_ENTRYPOINT_COLD_TASK_RECOVERY_SYNTHETIC_PASS' + - name: R316 enforce canonical per-user OS known-folder and reject redirected or linked roots + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R316-CanonicalRoot-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R316_NATIVE_PS51_PARSER_FAILED'} + ./build/Test-R316-CanonicalRoot-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R316_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R316_TRUSTED_APPROOT_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R316_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R316' -or + $r.Status -cne 'PASS_OS_KNOWNFOLDER_CANONICAL_APPROOT_AND_REPARSE_REFUSAL' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.ExactCompiledInstallerSource -ne $true -or + $r.CanonicalExistingAppRootAccepted -ne $true -or + $r.SyntheticSafeProfileAccepted -ne $true -or + $r.RelativeUNCTraversalAndForeignProfileRejected -ne $true -or + $r.ExistingJunctionAndAliasRejected -ne $true -or + $r.UserFilesAndExternalCanaryUnchanged -ne $true -or + $r.OriginalOwnerMachineTouched -ne $false -or + $r.ActualProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.CodeQLHighAlertsAccepted -ne $false -or + @($r.Checks).Count -ne 13){throw 'R316_EXACT_NATIVE_ROOT_BOUNDARY_GATES_NOT_ACCEPTED'} + 'R316_CANONICAL_KNOWNFOLDER_AND_REPARSE_PROTECTION_PASS_CODEQL_STILL_OPEN' + - name: R317 verify PE-pinned nonrecursive uninstall cleanup transport without deletion + shell: powershell + run: | + $ErrorActionPreference='Stop' + $proof=(Resolve-Path build/Test-R317-UninstallEncodedCleanup-Native.ps1).Path + $tok=$null;$errs=$null + [void][Management.Automation.Language.Parser]::ParseFile($proof,[ref]$tok,[ref]$errs) + if(@($errs).Count){$errs|ForEach-Object {$_.Message};throw 'R317_PS51_TEST_PARSER_INVALID'} + ./build/Test-R317-UninstallEncodedCleanup-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R317_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R317_ENCODED_CLEANUP_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R317_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R317' -or + $r.Status -cne 'PASS_EXACT_PE_INMEMORY_UNINSTALL_CLEANUP_TRANSPORT_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.MainInstallationUntouched -ne $true -or + $r.ActualCleanupProcessLaunched -ne $false -or + $r.ActualDirectoryDeleted -ne $false -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.NoMutableTempBatchOrCmd -ne $true -or + $r.ProcessHostAbsolutePinned -ne $true -or + $r.ShellArgumentsPureBase64 -ne $true -or + $r.WrongRootsAndInjectionRefused -ne $true -or + $r.ScriptSyntaxAndTokenGuardVerified -ne $true -or + $r.UserDataUnchanged -ne $true -or + $r.SyntheticNonrecursiveDeletionVerified -ne $true -or + $r.SyntheticJunctionRefusalVerified -ne $true -or + $r.TrueUninstallCleanupEndToEndProven -ne $false -or + @($r.Checks).Count -ne 23){throw 'R317_TYPED_SAFE_UNINSTALL_TRANSPORT_GATES_NOT_MET'} + 'R317_PRIVATE_PE_CLEANUP_TRANSPORT_SYNTHETIC_PASS_REAL_UNINSTALL_OPEN' + - name: R318 create and inspect COM shortcut without Temp PowerShell script + shell: powershell + run: | + $ErrorActionPreference='Stop' + $proof=(Resolve-Path build/Test-R318-InProcessShortcut-Native.ps1).Path + $tok=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($proof,[ref]$tok,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object {$_.Message};throw 'R318_PS51_TEST_SYNTAX_INVALID'} + ./build/Test-R318-InProcessShortcut-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R318_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R318_INPROCESS_SHORTCUT_RECEIPT.json') + if(-not (Test-Path -LiteralPath $receipt)){throw 'R318_RAW_COM_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R318' -or + $r.Status -cne 'PASS_EXACT_PE_INPROCESS_COM_SHORTCUT_AND_NO_TEMP_SCRIPT' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.OriginalProtectedSensorTaskTouched -ne $false -or + $r.ActualInstalledShortcutChanged -ne $false -or + $r.ExactPeSourceAndCOMMethodVerified -ne $true -or + $r.WScriptShortcutCreatedReadAndRemoved -ne $true -or + $r.AllShortcutPropertiesRoundtripped -ne $true -or + $r.PowerShellTempScriptEliminated -ne $true -or + $r.MetacharactersTreatedAsData -ne $true -or + $r.RelativeAndInvalidPathsRefused -ne $true -or + $r.RealInstalledUserDataUnchanged -ne $true -or + $r.RealOwnerInstallationOrUninstallInvoked -ne $false -or + @($r.Checks).Count -ne 18){throw 'R318_TYPED_NATIVE_SHORTCUT_PROOF_GATES_NOT_MET'} + 'R318_COM_SHORTCUT_NO_TEMP_SCRIPT_NATIVE_SYNTHETIC_PASS' + - name: R321 refuse substituted Main binary and pin its read handle before autolaunch + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R321-PinnedMainLaunch-Native.ps1).Path + $tok=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R321_NATIVE_TEST_PS51_SYNTAX_INVALID'} + ./build/Test-R321-PinnedMainLaunch-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R321_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R321_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R321' -or + $r.Status -cne 'PASS_PINNED_MAIN_BEFORE_AUTOLAUNCH_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.ExactPEInstallerSourceVerified -ne $true -or + $r.VerifiedMainSHA256 -cnotmatch '^[0-9A-F]{64}$' -or + $r.ReadHandleHeldAcrossLaunchPath -ne $true -or + $r.ExclusiveWriterDenied -ne $true -or + $r.RenameBeforeLaunchDenied -ne $true -or + $r.AlteredSameNameBinaryRejected -ne $true -or + $r.JunctionAndUnsafePathsRejected -ne $true -or + $r.MainWasNotActuallyLaunchedInFixture -ne $true -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.ActualProtectedTaskTouched -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.SecurityCodeQLAccepted -ne $false -or + $r.GuestMainAndUserSettingsPreserved -ne $true -or + @($r.Checks).Count -ne 14){throw 'R321_TYPED_PINNED_MAIN_PROOF_FAILED'} + 'R321_PINNED_IMMUTABLE_MAIN_HANDLE_BOUNDED=PASS_NOT_SECURITY_FINAL' + - name: Preserve bounded guest evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R286-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/R286_RECOVERY_RECEIPT.json + ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/ARMED.json + ${{ runner.temp }}/R290_${{ matrix.host }}_${{ github.run_id }}/R290_REPARSE_RECEIPT.json + ${{ runner.temp }}/R291_${{ matrix.host }}_${{ github.run_id }}/R291_BOUNDARY_RECEIPT.json + ${{ runner.temp }}/R293_${{ matrix.host }}_${{ github.run_id }}/R293_INMEMORY_RECEIPT.json + ${{ runner.temp }}/R294_${{ matrix.host }}_${{ github.run_id }}/R294_PROVENANCE_RECEIPT.json + ${{ runner.temp }}/R295_${{ matrix.host }}_${{ github.run_id }}/R295_ZIP_PROVENANCE_RECEIPT.json + ${{ runner.temp }}/R296_${{ matrix.host }}_${{ github.run_id }}/R296_MEMORY_ROLLBACK_RECEIPT.json + ${{ runner.temp }}/R297_${{ matrix.host }}_${{ github.run_id }}/R297_PAWN_REFUSAL_RECEIPT.json + ${{ runner.temp }}/R298_${{ matrix.host }}_${{ github.run_id }}/R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json + ${{ runner.temp }}/R299_${{ matrix.host }}_${{ github.run_id }}/R299_NONRECURSIVE_CLEANUP_RECEIPT.json + ${{ runner.temp }}/R300_${{ matrix.host }}_${{ github.run_id }}/R300_RETIRED_JOURNAL_RECEIPT.json + ${{ runner.temp }}/R301_${{ matrix.host }}_${{ github.run_id }}/R301_ROLLBACK_RETRY_RECEIPT.json + ${{ runner.temp }}/R302_${{ matrix.host }}_${{ github.run_id }}/R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json + ${{ runner.temp }}/R303_${{ matrix.host }}_${{ github.run_id }}/R303_PROTECTED_SENSOR_OWNER_RECEIPT.json + ${{ runner.temp }}/R304_${{ matrix.host }}_${{ github.run_id }}/R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json + ${{ runner.temp }}/R305_${{ matrix.host }}_${{ github.run_id }}/R305_ELEVATED_TOKEN_SID_RECEIPT.json + ${{ runner.temp }}/R306_${{ matrix.host }}_${{ github.run_id }}/R306_ROLLBACK_MODEL_RECEIPT.json + ${{ runner.temp }}/R307_${{ matrix.host }}_${{ github.run_id }}/R307_PROTECTED_JOURNAL_ACL_RECEIPT.json + ${{ runner.temp }}/R308_${{ matrix.host }}_${{ github.run_id }}/R308_JOURNAL_PREWRITE_RECEIPT.json + ${{ runner.temp }}/R309_${{ matrix.host }}_${{ github.run_id }}/R309_TERMINAL_REPLAY_RECEIPT.json + ${{ runner.temp }}/R310_${{ matrix.host }}_${{ github.run_id }}/R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json + ${{ runner.temp }}/R311_${{ matrix.host }}_${{ github.run_id }}/R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json + ${{ runner.temp }}/R312_${{ matrix.host }}_${{ github.run_id }}/R312_TRUSTED_TASK_XML_RECEIPT.json + ${{ runner.temp }}/R313_${{ matrix.host }}_${{ github.run_id }}/R313_TASK_REGISTRATION_RECEIPT.json + ${{ runner.temp }}/R314_${{ matrix.host }}_${{ github.run_id }}/R314_TASK_COLD_RESTORE_RECEIPT.json + ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json + ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json + ${{ runner.temp }}/R321_${{ matrix.host }}_${{ github.run_id }}/R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json + ${{ runner.temp }}/R317_${{ matrix.host }}_${{ github.run_id }}/R317_ENCODED_CLEANUP_RECEIPT.json + ${{ runner.temp }}/R318_${{ matrix.host }}_${{ github.run_id }}/R318_INPROCESS_SHORTCUT_RECEIPT.json + + + fresh-install-uninstall: + name: R319 fresh private install then uninstall on isolated ${{ matrix.host }} + needs: candidate + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact private candidate source, new isolated host + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Retrieve private not-for-release binaries only + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R286-PRIVATE-NOT-PUBLIC-RELEASE + path: _r284_private_candidate + - name: R319 fresh Windows host safety and PowerShell 5.1 syntax preflight + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R319_NOT_EXACT_DISPOSABLE_SOURCE'} + $test=(Resolve-Path build/Test-R319-ActualHostedInstallUninstall-Native.ps1).Path + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$t,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R319_NATIVE_PS51_SYNTAX_INVALID'} + if(!(Test-Path '_r284_private_candidate/CANDIDATE.json')){throw 'R319_PRIVATE_MANIFEST_MISSING'} + $c=Get-Content '_r284_private_candidate/CANDIDATE.json' -Raw|ConvertFrom-Json + if($c.Commit -cne $env:GITHUB_SHA -or $c.Private -ne $true -or + $c.NotForPublicInstallation -ne $true){throw 'R319_UNTRUSTED_OR_PUBLIC_CANDIDATE'} + 'R319_NEW_HOST_STRICT_PREFLIGHT_PASS' + - name: R319 run actual private 1.7.8 install uninstall and deferred cleanup on new Windows guest + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R319-ActualHostedInstallUninstall-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R319_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R319_PRIVATE_INSTALL_UNINSTALL_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R319_TYPED_RAW_RECEIPT_ABSENT'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R319' -or + $r.Status -cne 'PASS_REAL_PRIVATE_INSTALL_UNINSTALL_ON_DISPOSABLE_HOST' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.PrivateCandidateOnly -ne $true -or $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualWindowsHostedInstallExecuted -ne $true -or + $r.ActualWindowsHostedUninstallExecuted -ne $true -or + $r.ActualProductUserRootRemoved -ne $true -or + $r.ActualProtectedTaskAbsent -ne $true -or + $r.ActualProtectedBrokerAbsent -ne $true -or + $r.ActualShortcutLinksRemoved -ne $true -or + $r.ConfirmedNoBundledPawnIODriverLaunched -ne $true -or + $r.TrueUACPromptInteractionTested -ne $false -or + $r.InstallExitCode -ne 0 -or $r.UninstallExitCode -ne 0 -or + $r.MainInstalledSHA256 -cne $r.MainExpectedSHA256 -or + $r.UninstallerInstalledSHA256 -cne $r.SetupExpectedSHA256 -or + [string]::IsNullOrWhiteSpace([string]$r.SensorStatus) -or + $r.DeferredCleanupSeconds -gt 70 -or $r.Failure -or + @($r.Checks).Count -ne 18){throw 'R319_REAL_HOSTED_INSTALL_UNINSTALL_RECEIPT_GATES_FAILED'} + 'R319_PRIVATE_HOSTED_INSTALL_UNINSTALL_SUCCESS_NON_RELEASE' + - name: Preserve bounded R319 guest receipt even on failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R319-new-host-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 14 + path: | + ${{ runner.temp }}/R319_${{ matrix.host }}_${{ github.run_id }}/R319_PRIVATE_INSTALL_UNINSTALL_RECEIPT.json diff --git a/.github/workflows/audit-r316-codeql-root.yml b/.github/workflows/audit-r316-codeql-root.yml new file mode 100644 index 00000000..cfc9ee43 --- /dev/null +++ b/.github/workflows/audit-r316-codeql-root.yml @@ -0,0 +1,95 @@ +name: R316 Compare Canonical Root CodeQL + +on: + push: + branches: + - audit/r308-sensor-journal-prewrite + paths: + - installer/TaskbarMonitorEnhanced_Setup.cs + - .github/workflows/audit-r316-codeql-root.yml + +permissions: + contents: read + actions: read + +jobs: + source-to-sink: + name: CodeQL ${{ matrix.variant }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + steps: + - name: Checkout comparison target + uses: actions/checkout@v4 + with: + ref: ${{ matrix.variant == 'baseline' && '7a8f0bcb86f9a1be0c6ad0d0d019472d6fd62bee' || github.sha }} + persist-credentials: false + - name: Initialize CodeQL same engine and queries + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: none + config: | + threat-models: local + - name: Analyze without uploading or dismissing any scanning alerts + uses: github/codeql-action/analyze@v4 + with: + upload: never + output: codeql-results + - name: Compare Default Setup parity of C sharp sink findings from raw SARIF + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $files=@(Get-ChildItem -LiteralPath codeql-results -Recurse -Filter '*.sarif' -File) + if($files.Count -lt 1){throw 'R316_CODEQL_SARIF_NOT_GENERATED'} + $compiledReferences=0 + $compilerErrorCount=0 + $relevant=@() + $allCount=0 + foreach($file in $files){ + $sarif=Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -Depth 100 + foreach($run in @($sarif.runs)){ + $compiledReferences+=@($run.artifacts|Where-Object { [string]$_.location.uri -like '*TaskbarMonitorEnhanced_Setup.cs*' }).Count + foreach($invocation in @($run.invocations)){ + $compilerErrorCount+=@($invocation.toolExecutionNotifications|Where-Object {[string]$_.message.text -match 'Error CS[0-9]{4}'}).Count + } + $allCount+=@($run.results).Count + $relevant+=@($run.results|Where-Object { + $_.ruleId -in @('cs/path-injection','cs/command-line-injection') + }|ForEach-Object { + [pscustomobject]@{Rule=$_.ruleId;Path=$_.locations[0].physicalLocation.artifactLocation.uri;Line=$_.locations[0].physicalLocation.region.startLine} + }) + } + } + if($compiledReferences -lt 1){ + throw ('R316_CODEQL_SOURCE_COVERAGE_NOT_VALID references='+$compiledReferences) + } + if('${{ matrix.variant }}' -eq 'baseline' -and $relevant.Count -lt 1){ + throw 'R316_BASELINE_CODEQL_QUERY_COVERAGE_UNTRUSTWORTHY_ZERO' + } + $summary=[ordered]@{ + Schema=1;Revision='R316';Mode='csharp-default-ubuntu-none-remote-and-local' + Variant='${{ matrix.variant }}' + AnalyzedCommit=(git rev-parse HEAD).Trim() + WorkflowCommit='${{ github.sha }}' + GeneratedSarifCount=$files.Count;AllFindings=$allCount;CompiledSetupReferences=$compiledReferences;CompilerErrors=$compilerErrorCount + ThreatModel='remote_and_local' + TargetFindingCount=$relevant.Count + PathInjectionCount=@($relevant|Where-Object Rule -eq 'cs/path-injection').Count + CommandLineInjectionCount=@($relevant|Where-Object Rule -eq 'cs/command-line-injection').Count + TargetFindings=@($relevant);SecurityAccepted=$false + } + [IO.File]::WriteAllText('r316-codeql-summary.json',($summary|ConvertTo-Json -Depth 10),[Text.UTF8Encoding]::new($false)) + 'R316_CODEQL_COMPARE_VARIANT=${{ matrix.variant }} TARGET_COUNT='+$relevant.Count+' ALL_COUNT='+$allCount + - name: Preserve raw SARIF and bounded comparison summary + uses: actions/upload-artifact@v4 + with: + name: r316-codeql-${{ matrix.variant }}-${{ github.run_id }} + path: | + codeql-results + r316-codeql-summary.json + if-no-files-found: error + retention-days: 3 diff --git a/.github/workflows/audit-r329-pinned-main-ancestor.yml b/.github/workflows/audit-r329-pinned-main-ancestor.yml new file mode 100644 index 00000000..fdda0bef --- /dev/null +++ b/.github/workflows/audit-r329-pinned-main-ancestor.yml @@ -0,0 +1,90 @@ +name: R329 SHA-pinned Main ancestor rename native A/B + +on: + push: + branches: [audit/r329-main-ancestor-native-temp] + workflow_dispatch: + +permissions: + contents: read + +jobs: + pinned-main-ancestor: + name: Pinned Main ancestor rename negative on Windows ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 18 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact review-only source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Verify isolated host and exact commit + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or + (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R329_REFUSE_NON_DISPOSABLE_OR_UNPINNED_SOURCE' + } + 'R329_DISPOSABLE_SOURCE_PRECONDITION=PASS' + - name: Set up pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build private reproducible candidate on guest (no owner installation) + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R329_HOSTED_PRIVATE_BUILD_FAILED'} + if(!(Test-Path 'build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe')){ + throw 'R329_EXPECTED_PRIVATE_PE_NOT_BUILT' + } + 'R329_PRIVATE_NOT_FOR_RELEASE_BUILD=PASS' + - name: Prove exact pinned Main blocks cross-process and ancestor rename + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R329-PinnedMainAncestorRename-Native.ps1).Path + $tok=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$errors) + if(@($errors).Count){throw 'R329_NATIVE_PS51_SCRIPT_PARSER_INVALID'} + $setup=(Resolve-Path build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe).Path + $win=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + & $win -NoProfile -NonInteractive -File $test -SetupPath $setup -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw ('R329_SCOPED_NATIVE_TEST_FAILED_EXIT_'+$LASTEXITCODE)} + $receipt=Join-Path $env:RUNNER_TEMP ('R329_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R329_HOSTED_RAW_RECEIPT_NOT_CREATED'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R329' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.Status -cne 'PASS_PRODUCTION_PINNED_MAIN_PREVENTS_SAME_AND_CROSS_PROCESS_RENAME' -or + @($r.Checks).Count -ne 19 -or $r.ActualUACInvoked -ne $false -or + $r.MainExecutableWasLaunched -ne $false -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.ProtectedProgramFilesChanged -ne $false -or $r.SecurityFinalAccepted -ne $false -or + $r.SetupSHA256 -cne (Get-FileHash $setup -Algorithm SHA256).Hash -or + $r.MainSHA256 -cne (Get-FileHash 'build/_out/App/TaskbarMonitorEnhanced.exe' -Algorithm SHA256).Hash){ + throw 'R329_HOSTED_RECEIPT_IDENTITY_AND_SCOPE_FAILED' + } + 'R329_MAIN_PINNED_ACROSS_PARENT_RENAME_HOSTED=19/19_NO_UAC' + - name: Store bounded SHA-verifiable guest receipt only + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R329-ancestor-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R329_${{ matrix.host }}_${{ github.run_id }}/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json diff --git a/.github/workflows/audit-r337-first-install-recovery.yml b/.github/workflows/audit-r337-first-install-recovery.yml new file mode 100644 index 00000000..5ed7b352 --- /dev/null +++ b/.github/workflows/audit-r337-first-install-recovery.yml @@ -0,0 +1,87 @@ +name: R337 first-sensor-install cold recovery differential (disposable) +on: + push: + branches: [audit/r337-orphan-broker-recovery] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R337-FirstInstallColdOrphan-Native.ps1 + - .github/workflows/audit-r337-first-install-recovery.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + first-install-cold-recovery: + name: R337 ${{ matrix.variant }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact test harness branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + path: harness + persist-credentials: false + - name: Checkout exact prior source or current candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ matrix.variant == 'baseline' && 'fa57ae2c89304974f4b91d5a9ca7c3d5b06f57e1' || github.sha }} + path: src + persist-credentials: false + - name: Require hosted Windows and exact pinned source + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $expected="${{ matrix.variant == 'baseline' && 'fa57ae2c89304974f4b91d5a9ca7c3d5b06f57e1' || github.sha }}" + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + (git -C src rev-parse HEAD).Trim() -cne $expected -or + (git -C harness rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R337_NON_DISPOSABLE_OR_WRONG_CHECKOUT_REF' + } + if((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System').ConsentPromptBehaviorAdmin -ne 0){ + 'R337_UAC_POLICY_NOT_ZERO_DO_NOT_ASSUME_INTERACTIVE_UAC_TESTED' + } + 'R337_SOURCE='+$expected + - name: Install pinned SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Compile exact source with embedded real Windows PowerShell Helper + shell: pwsh + working-directory: src + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R337_SOURCE_COMPILATION_FAILED'} + if(!(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R337_EXACT_SETUP_EXE_MISSING' + } + - name: Execute sandboxed PS5.1 first-install cold replay differential + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path harness/build/Test-R337-FirstInstallColdOrphan-Native.ps1).Path + $product=(Resolve-Path src).Path + $setup=(Resolve-Path src/build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -Variant '${{ matrix.variant }}' -ProductRoot $product -SetupPath $setup + if($LASTEXITCODE -ne 0){throw 'R337_HOSTED_NATIVE_COLD_RECOVERY_NEGATIVE_OR_REGRESSION'} + - name: Retain typed test evidence without public security alert upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R337-${{ matrix.variant }}-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R337_*/R337_FIRST_INSTALL_COLD_RECOVERY.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r338-manual-csharp-codeql.yml b/.github/workflows/audit-r338-manual-csharp-codeql.yml new file mode 100644 index 00000000..7adbbec9 --- /dev/null +++ b/.github/workflows/audit-r338-manual-csharp-codeql.yml @@ -0,0 +1,132 @@ +name: R338 Manual compiled CSharp CodeQL evidence + +on: + push: + branches: + - audit/r338-supervisor-argv-trust + paths: + - .github/workflows/audit-r338-manual-csharp-codeql.yml + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + manual-compiled-csharp: + name: Manual compiled CSharp ${{ matrix.variant }} on Windows + runs-on: windows-2025 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + steps: + - name: Checkout exact reviewed product source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || 'edf2610c20789028050b3ea76b1bee01c77d07bf' }} + persist-credentials: false + - name: Assert disposable Windows and exact committed comparison source + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $expected="${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || 'edf2610c20789028050b3ea76b1bee01c77d07bf' }}" + if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or + (git rev-parse HEAD).Trim() -cne $expected) { + throw 'R338_EXPECTED_DISPOSABLE_EXACT_PRODUCT_COMMIT_NOT_CHECKED_OUT' + } + 'R338_PINNED_WINDOWS_COMPILE_SOURCE='+$expected + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Initialize CodeQL for actual compiled CSharp + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: manual + config: | + threat-models: local + - name: Build exact four .NET Framework projects under CodeQL tracer + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -File ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R338_MANUAL_CODEQL_PRIVATE_REPRO_BUILD_FAILED'} + $required=@( + 'build/_out/App/TaskbarMonitorEnhanced.exe', + 'build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe', + 'build/_out/Broker/TaskbarMonitorSensorBroker.exe', + 'build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe' + ) + foreach($p in $required){if(!(Test-Path -LiteralPath $p -PathType Leaf)){throw ('R338_EXPECTED_COMPILED_EXE_MISSING_'+$p)}} + 'R338_FOUR_REAL_COMPILED_CSHARP_PE=PASS' + - name: Analyze compiled sources without publishing security alerts + uses: github/codeql-action/analyze@v4 + with: + upload: never + output: codeql-results + - name: Verify raw compiled-source coverage and report diagnostics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $sarifs=@(Get-ChildItem -LiteralPath codeql-results -Recurse -File -Filter '*.sarif') + if($sarifs.Count -ne 1){throw ('R338_MANUAL_CODEQL_EXPECTED_ONE_CSHARP_SARIF_GOT_'+$sarifs.Count)} + $sarif=Get-Content $sarifs[0].FullName -Raw|ConvertFrom-Json -Depth 100 + if(@($sarif.runs).Count -ne 1){throw 'R338_COMPILED_SARIF_RUN_COUNT_NOT_ONE'} + $run=$sarif.runs[0] + $artifacts=@($run.artifacts) + $files=@($artifacts|ForEach-Object {$_.location.uri}) + $setupRefs=@($files|Where-Object {$_ -like '*TaskbarMonitorEnhanced_Setup.cs*'}).Count + $appRefs=@($files|Where-Object {$_ -like '*TaskbarMonitorEnhanced.cs*'}).Count + $brokerRefs=@($files|Where-Object {$_ -like '*TaskbarMonitorSensorBroker.cs*'}).Count + $errors=0 + foreach($invocation in @($run.invocations)){ + $errors+=@($invocation.toolExecutionNotifications|Where-Object {$_.message.text -match 'Error CS[0-9]{4}'}).Count + } + if($setupRefs -lt 1 -or $appRefs -lt 1 -or $brokerRefs -lt 1){ + throw ('R338_MANUAL_CODEQL_COMPILED_SOURCE_COVERAGE_INCOMPLETE:Setup='+$setupRefs+' App='+$appRefs+' Broker='+$brokerRefs) + } + $findings=@($run.results) + $setup=@($findings|Where-Object {$_.locations[0].physicalLocation.artifactLocation.uri -like '*TaskbarMonitorEnhanced_Setup.cs*'}) + $supervisor=@($findings|Where-Object {$_.locations[0].physicalLocation.artifactLocation.uri -like '*TaskbarMonitorSensorSupervisor.cs*'}) + $expected="${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || 'edf2610c20789028050b3ea76b1bee01c77d07bf' }}" + if((git rev-parse HEAD).Trim() -cne $expected){throw 'R338_MANUAL_RESULTS_NOT_AT_CHECKED_OUT_SHA'} + $summary=[ordered]@{ + Schema=1 + Revision='R338' + SourceCommit=$expected + AnalysisWorkflowCommit='${{ github.sha }}' + Variant='${{ matrix.variant }}' + Status='SCOPED_MANUAL_BUILD_CSHARP_CODEQL_RAW_RESULTS' + AnalyzerBuildMode='manual' + ThreatModel='remote_and_local' + FourRealFrameworkProjectsBuilt=$true + CompiledSetupSourceCount=$setupRefs + CompiledMainSourceCount=$appRefs + CompiledBrokerSourceCount=$brokerRefs + CompilerErrorNotifications=$errors + TotalFindings=$findings.Count + SetupFindings=$setup.Count + SetupPath=@($setup|Where-Object ruleId -eq 'cs/path-injection').Count + SetupCommand=@($setup|Where-Object ruleId -eq 'cs/command-line-injection').Count + SupervisorCommand=@($supervisor|Where-Object ruleId -eq 'cs/command-line-injection').Count + WholeSecurityAccepted=$false + RealElevatedUacHelperCrashTested=$false + ProducedWithoutPublicCodeScanningUpload=$true + } + $outfile='r338-manual-codeql-summary.json' + [IO.File]::WriteAllText($outfile,($summary|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + 'R338_MANUAL_CSHARP_VARIANT=${{ matrix.variant }} SETUP='+$setup.Count+' ALL='+$findings.Count+' COMPILER_NOTIFICATIONS='+$errors + - name: Retain compact raw SARIF and scoped summary + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R338-manual-csharp-${{ matrix.variant }}-${{ github.run_id }} + path: | + codeql-results + r338-manual-codeql-summary.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r338-supervisor-argv-trust.yml b/.github/workflows/audit-r338-supervisor-argv-trust.yml new file mode 100644 index 00000000..ca00788d --- /dev/null +++ b/.github/workflows/audit-r338-supervisor-argv-trust.yml @@ -0,0 +1,87 @@ +name: R338 Supervisor trusted argv binding (disposable Windows) +on: + push: + branches: [audit/r338-supervisor-argv-trust] + paths: + - src/sensors/TaskbarMonitorSensorSupervisor.cs + - build/Test-R338-SupervisorTrustedArgs-Native.ps1 + - .github/workflows/audit-r338-supervisor-argv-trust.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + native-argv-contract: + name: R338 ${{ matrix.variant }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact test harness revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + path: harness + persist-credentials: false + - name: Checkout exact baseline or candidate product source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || github.sha }} + path: src + persist-credentials: false + - name: Require disposable Windows exact source and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $expected="${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || github.sha }}" + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID' -or + (git -C src rev-parse HEAD).Trim() -cne $expected -or + (git -C harness rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R338_NOT_PINNED_DISPOSABLE_WINDOWS' + } + $actual=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if(Test-Path -LiteralPath $actual){throw 'R338_HOSTED_EXISTING_LIVE_INSTALL_REFUSE'} + 'R338_RUNNER_DISPOSABLE_AND_SOURCE_BOUND='+$expected + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Compile actual supervisor and all bundled .NET Framework projects + shell: pwsh + working-directory: src + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe -PathType Leaf)){ + throw 'R338_EXACT_COMPILED_SUPERVISOR_MISSING' + } + 'R338_SUPERVISOR_AND_PRODUCT_FULL_BUILD_PASS' + - name: Execute original compiled PE parser and native junction negatives (no launch) + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $ps=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path harness/build/Test-R338-SupervisorTrustedArgs-Native.ps1).Path + $source=(Resolve-Path src).Path + $exe=(Resolve-Path src/build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe).Path + & $ps -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostedGuest -HostLabel '${{ matrix.host }}' -Variant '${{ matrix.variant }}' -SourceRoot $source -SupervisorPath $exe + if($LASTEXITCODE -ne 0){throw 'R338_SUPERVISOR_ARG_PATH_NATIVE_GUEST_NOT_ACCEPTED'} + - name: Retain raw typed guest receipt without security alert upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R338-${{ matrix.variant }}-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R338_*/R338_SUPERVISOR_ARG_TRUST.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r339-cross-user-protected-programfiles.yml b/.github/workflows/audit-r339-cross-user-protected-programfiles.yml new file mode 100644 index 00000000..3f2d3934 --- /dev/null +++ b/.github/workflows/audit-r339-cross-user-protected-programfiles.yml @@ -0,0 +1,87 @@ +name: R339 genuine second Windows SID vs protected ProgramFiles (disposable) +on: + push: + branches: [audit/r339-two-user-protected-broker] + paths: + - build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 + - build/Test-R339-SecondAccount-Actor.ps1 + - .github/workflows/audit-r339-cross-user-protected-programfiles.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + cross-user-protected-programfiles: + name: R339 native Windows ${{ matrix.host }} real two users + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact test-only source with no credential persistence + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Require only disposable hosted Windows exact SHA before native account or protected root mutation + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r339-two-user-protected-broker' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R339_REFUSE_NON_DISPOSABLE_OR_NONPINNED_HOST' + } + $program=[Environment]::GetFolderPath('ProgramFiles') + $app=Join-Path $program 'TaskbarMonitorEnhanced' + $local=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $local)){ + throw 'R339_REAL_PRODUCT_INSTALL_OR_PREEXISTING_APP_ON_GUEST' + } + $token=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$token.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R339_ALREADY_ADMIN_TOKEN_REQUIRED_NOT_REAL_UAC' + } + 'R339_EXACT_DISPOSABLE_HOST_AND_NO_EXISTING_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Compile actual candidate Supervisor and Broker (never launch either) + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe) -or + !(Test-Path -LiteralPath build/_out/Broker/TaskbarMonitorSensorBroker.exe)){ + throw 'R339_COMPILED_PROTECTED_BINARIES_ABSENT' + } + 'R339_CANDIDATE_ALL_FOUR_COMPONENTS_BUILD_SUCCESS' + - name: Run two genuine local Windows accounts against admin-owned protected ProgramFiles + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $runner=(Resolve-Path build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $runner -HostLabel '${{ matrix.host }}' -CandidateRoot $source + if($LASTEXITCODE -ne 0){throw 'R339_REAL_SECOND_USER_PROTECTED_PROGRAMFILES_SECURITY_GATE_FAILED'} + - name: Retain typed two-user guest evidence without public scanner upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R339-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R339_*/R339_PROTECTED_PROGRAMFILES_TWO_USERS.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml new file mode 100644 index 00000000..09c544e9 --- /dev/null +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -0,0 +1,118 @@ +name: R344 authenticated protected uninstall journal original Helper (hosted Windows) +on: + push: + branches: [audit/r344-uninstall-prepared-journal-guard] + paths: + - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 + - build/Test-R344-JournalAcl-MutationRights-Native.ps1 + - build/Test-R344-UnownedSensorNoJournal-Native.ps1 + - installer/TBME_Setup_Elevated_Helper.ps1 + - .github/workflows/audit-r340-real-original-helper-uninstall.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-helper-uninstall: + name: R344 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [nominal, killed, posttask, postfile] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact revision without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse any non-disposable host, prior product, task or wrong ref + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R340_REFUSE_WRONG_GUEST_OR_WRONG_SOURCE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe' + if((Test-Path -LiteralPath $root) -or (Test-Path -LiteralPath $main) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R340_ALREADY_INSTALLED_PRODUCT_OR_SENSOR_TASK_GUEST_REFUSED' + } + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_NOT_ALREADY_ADMIN_NEVER_DISABLE_UAC' + } + 'R340_DISPOSABLE_ADMIN_TOKEN_AND_NO_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build exact reviewed product including production PE-embedded Helper + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R340_PRODUCT_EXACT_SETUP_NOT_COMPILED' + } + 'R340_ALL_FOUR_FRAMEWORK_PRODUCT_COMPONENTS_BUILD_SUCCESS' + - name: Validate real PE-embedded journal ACL reject/allow semantics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-JournalAcl-MutationRights-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ACTUAL_ACL_BIT_NEGATIVE_FAILED'} + - name: Refuse unowned or foreign protected Broker without a real journal + if: matrix.mode == 'nominal' + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-UnownedSensorNoJournal-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ORIGINAL_HELPER_UNOWNED_WITHOUT_JOURNAL_REFUSAL_FAILED'} + - name: Launch exact unmodified RunAs production Helper on owned disabled test task + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R340-OriginalHelperActualUninstall-Native.ps1).Path + $source=(Resolve-Path .).Path + $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) + if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} + if('${{ matrix.mode }}' -ceq 'posttask'){$nativeArgs+='-KillAfterTaskRemoval'} + if('${{ matrix.mode }}' -ceq 'postfile'){$nativeArgs+='-KillAfterBrokerRemoval'} + & $native @nativeArgs + if($LASTEXITCODE -ne 0){ + throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' + } + - name: Retain typed exact original Helper execution proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R340-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + ${{ runner.temp }}/R344Acl_*/R344_JOURNAL_ACL_MASK.json + ${{ runner.temp }}/R344Neg_*/R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r347-original-helper-regression.yml b/.github/workflows/audit-r347-original-helper-regression.yml new file mode 100644 index 00000000..17170a8e --- /dev/null +++ b/.github/workflows/audit-r347-original-helper-regression.yml @@ -0,0 +1,118 @@ +name: R347 full original Helper regression against broker directory leases (Windows) +on: + push: + branches: [audit/r347-protected-broker-handle-lease] + paths: + - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 + - build/Test-R344-JournalAcl-MutationRights-Native.ps1 + - build/Test-R344-UnownedSensorNoJournal-Native.ps1 + - installer/TBME_Setup_Elevated_Helper.ps1 + - .github/workflows/audit-r347-original-helper-regression.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-helper-uninstall: + name: R347 regression ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [nominal, killed, posttask, postfile] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact revision without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse any non-disposable host, prior product, task or wrong ref + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R340_REFUSE_WRONG_GUEST_OR_WRONG_SOURCE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe' + if((Test-Path -LiteralPath $root) -or (Test-Path -LiteralPath $main) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R340_ALREADY_INSTALLED_PRODUCT_OR_SENSOR_TASK_GUEST_REFUSED' + } + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_NOT_ALREADY_ADMIN_NEVER_DISABLE_UAC' + } + 'R340_DISPOSABLE_ADMIN_TOKEN_AND_NO_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build exact reviewed product including production PE-embedded Helper + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R340_PRODUCT_EXACT_SETUP_NOT_COMPILED' + } + 'R340_ALL_FOUR_FRAMEWORK_PRODUCT_COMPONENTS_BUILD_SUCCESS' + - name: Validate real PE-embedded journal ACL reject/allow semantics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-JournalAcl-MutationRights-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ACTUAL_ACL_BIT_NEGATIVE_FAILED'} + - name: Refuse unowned or foreign protected Broker without a real journal + if: matrix.mode == 'nominal' + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-UnownedSensorNoJournal-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ORIGINAL_HELPER_UNOWNED_WITHOUT_JOURNAL_REFUSAL_FAILED'} + - name: Launch exact unmodified RunAs production Helper on owned disabled test task + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R340-OriginalHelperActualUninstall-Native.ps1).Path + $source=(Resolve-Path .).Path + $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) + if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} + if('${{ matrix.mode }}' -ceq 'posttask'){$nativeArgs+='-KillAfterTaskRemoval'} + if('${{ matrix.mode }}' -ceq 'postfile'){$nativeArgs+='-KillAfterBrokerRemoval'} + & $native @nativeArgs + if($LASTEXITCODE -ne 0){ + throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' + } + - name: Retain typed exact original Helper execution proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R347-regression-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + ${{ runner.temp }}/R344Acl_*/R344_JOURNAL_ACL_MASK.json + ${{ runner.temp }}/R344Neg_*/R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r347-protected-broker-lease.yml b/.github/workflows/audit-r347-protected-broker-lease.yml new file mode 100644 index 00000000..280d56d3 --- /dev/null +++ b/.github/workflows/audit-r347-protected-broker-lease.yml @@ -0,0 +1,90 @@ +name: R347 real privileged process rename vs protected Broker handle lease (disposable Windows) +on: + push: + branches: [audit/r347-protected-broker-handle-lease] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R347-PrivilegedDirectoryLease-Native.ps1 + - build/Test-R347-OtherAdminRename-Actor.ps1 + - .github/workflows/audit-r347-protected-broker-lease.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + privileged-concurrent-rename: + name: R347 real two privileged process handles ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact candidate without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Enforce disposable already-admin Windows and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R347_HOST_OR_COMMIT_NOT_DISPOSABLE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $root) -or + (Test-Path -LiteralPath (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count){ + throw 'R347_HOST_ALREADY_HAS_REAL_PRODUCT' + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(-not $isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R347_NO_EXISTING_ADMIN_TOKEN' + } + 'R347_DISPOSABLE_NO_PRODUCT_PRECONDITIONS_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build all four original Framework projects with embedded lease + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R347_COMPILED_ORIGINAL_SETUP_MISSING' + } + 'R347_EXACT_COMPILED_PRODUCT_BUILD_PASS' + - name: Real other admin process rename and junction refusal on private test-only protected root + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path build/Test-R347-PrivilegedDirectoryLease-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R347_TRUE_PRIVILEGED_DIRECTORY_LEASE_NOT_ACCEPTED'} + - name: Retain raw typed privileged Windows proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R347-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R347_*/R347_PINNED_DIRECTORY_LEASE.json + ${{ runner.temp }}/R347_*/OTHER_ADMIN_RENAME_RECEIPT.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r348-privileged-leaf-negative.yml b/.github/workflows/audit-r348-privileged-leaf-negative.yml new file mode 100644 index 00000000..cdbcf727 --- /dev/null +++ b/.github/workflows/audit-r348-privileged-leaf-negative.yml @@ -0,0 +1,90 @@ +name: R348 privileged leaf overwrite negative characterization (disposable Windows) +on: + push: + branches: [audit/r348-privileged-leaf-race-negative] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R348-PrivilegedLeaf-Native.ps1 + - build/Test-R348-PrivilegedLeaf-Actor.ps1 + - .github/workflows/audit-r348-privileged-leaf-negative.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + privileged-leaf-negative-characterization: + name: R348 privileged leaf overwrite ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact candidate without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Enforce disposable already-admin Windows and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r348-privileged-leaf-race-negative' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R348_HOST_OR_COMMIT_NOT_DISPOSABLE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $root) -or + (Test-Path -LiteralPath (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count){ + throw 'R348_HOST_ALREADY_HAS_REAL_PRODUCT' + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(-not $isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R348_NO_EXISTING_ADMIN_TOKEN' + } + 'R348_DISPOSABLE_NO_PRODUCT_PRECONDITIONS_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build all four original Framework projects with embedded lease + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R348_COMPILED_ORIGINAL_SETUP_MISSING' + } + 'R348_EXACT_COMPILED_PRODUCT_BUILD_PASS' + - name: Observe Administrator child leaf overwrite while original directory handles pinned + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path build/Test-R348-PrivilegedLeaf-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R348_NEGATIVE_LEAF_DIAGNOSTIC_NOT_COMPLETED'} + - name: Retain raw typed privileged Windows proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R348-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R348_*/R348_PRIVILEGED_LEAF_NEGATIVE.json + ${{ runner.temp }}/R348_*/R348_ACTOR_RECEIPT.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r349-original-helper-regression.yml b/.github/workflows/audit-r349-original-helper-regression.yml new file mode 100644 index 00000000..50dca4c3 --- /dev/null +++ b/.github/workflows/audit-r349-original-helper-regression.yml @@ -0,0 +1,118 @@ +name: R349 full original Helper regression against broker directory leases (Windows) +on: + push: + branches: [audit/r349-pinned-leaf-replay-handles] + paths: + - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 + - build/Test-R344-JournalAcl-MutationRights-Native.ps1 + - build/Test-R344-UnownedSensorNoJournal-Native.ps1 + - installer/TBME_Setup_Elevated_Helper.ps1 + - .github/workflows/audit-r349-original-helper-regression.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-helper-uninstall: + name: R349 regression ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [nominal, killed, posttask, postfile] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact revision without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse any non-disposable host, prior product, task or wrong ref + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R340_REFUSE_WRONG_GUEST_OR_WRONG_SOURCE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe' + if((Test-Path -LiteralPath $root) -or (Test-Path -LiteralPath $main) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R340_ALREADY_INSTALLED_PRODUCT_OR_SENSOR_TASK_GUEST_REFUSED' + } + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_NOT_ALREADY_ADMIN_NEVER_DISABLE_UAC' + } + 'R340_DISPOSABLE_ADMIN_TOKEN_AND_NO_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build exact reviewed product including production PE-embedded Helper + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R340_PRODUCT_EXACT_SETUP_NOT_COMPILED' + } + 'R340_ALL_FOUR_FRAMEWORK_PRODUCT_COMPONENTS_BUILD_SUCCESS' + - name: Validate real PE-embedded journal ACL reject/allow semantics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-JournalAcl-MutationRights-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ACTUAL_ACL_BIT_NEGATIVE_FAILED'} + - name: Refuse unowned or foreign protected Broker without a real journal + if: matrix.mode == 'nominal' + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-UnownedSensorNoJournal-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ORIGINAL_HELPER_UNOWNED_WITHOUT_JOURNAL_REFUSAL_FAILED'} + - name: Launch exact unmodified RunAs production Helper on owned disabled test task + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R340-OriginalHelperActualUninstall-Native.ps1).Path + $source=(Resolve-Path .).Path + $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) + if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} + if('${{ matrix.mode }}' -ceq 'posttask'){$nativeArgs+='-KillAfterTaskRemoval'} + if('${{ matrix.mode }}' -ceq 'postfile'){$nativeArgs+='-KillAfterBrokerRemoval'} + & $native @nativeArgs + if($LASTEXITCODE -ne 0){ + throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' + } + - name: Retain typed exact original Helper execution proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R349-regression-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + ${{ runner.temp }}/R344Acl_*/R344_JOURNAL_ACL_MASK.json + ${{ runner.temp }}/R344Neg_*/R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r349-privileged-leaf-native.yml b/.github/workflows/audit-r349-privileged-leaf-native.yml new file mode 100644 index 00000000..2fb9e8d6 --- /dev/null +++ b/.github/workflows/audit-r349-privileged-leaf-native.yml @@ -0,0 +1,90 @@ +name: R349 privileged Broker leaf overwrite and preopened DELETE denial (disposable Windows) +on: + push: + branches: [audit/r349-pinned-leaf-replay-handles] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R349-PrivilegedLeaf-Native.ps1 + - build/Test-R349-PrivilegedLeaf-Actor.ps1 + - .github/workflows/audit-r349-privileged-leaf-native.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + privileged-leaf-negative-characterization: + name: R349 protected leaf lock ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact candidate without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Enforce disposable already-admin Windows and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R349_HOST_OR_COMMIT_NOT_DISPOSABLE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $root) -or + (Test-Path -LiteralPath (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count){ + throw 'R349_HOST_ALREADY_HAS_REAL_PRODUCT' + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(-not $isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R349_NO_EXISTING_ADMIN_TOKEN' + } + 'R349_DISPOSABLE_NO_PRODUCT_PRECONDITIONS_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build all four original Framework projects with embedded lease + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R349_COMPILED_ORIGINAL_SETUP_MISSING' + } + 'R349_EXACT_COMPILED_PRODUCT_BUILD_PASS' + - name: Verify second Administrator leaf overwrite and prior DELETE handles blocked + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path build/Test-R349-PrivilegedLeaf-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R349_NATIVE_LEAF_SHARING_ACCEPTANCE_NOT_PASSED'} + - name: Retain raw typed privileged Windows proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R349-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R349_*/R349_PINNED_LEAF_NATIVE.json + ${{ runner.temp }}/R349_*/R349_ACTOR_RECEIPT.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r354-real-deferred-cleanup.yml b/.github/workflows/audit-r354-real-deferred-cleanup.yml new file mode 100644 index 00000000..a65cea18 --- /dev/null +++ b/.github/workflows/audit-r354-real-deferred-cleanup.yml @@ -0,0 +1,100 @@ +name: R354 real production Setup deferred cleanup (disposable Windows) +on: + push: + branches: [audit/r355-deferred-cleanup-directory-pins] + paths: + - build/Test-R354-OriginalDeferredCleanup-Native.ps1 + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Build.ps1 + - .github/workflows/audit-r354-real-deferred-cleanup.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + real-deferred-cleanup: + name: R354 ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [Nominal, RootJunction, ChildJunction] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-hosted or preexisting real product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R354_REFUSE_WRONG_SOURCE_OR_NONDISPOSABLE_RUNNER' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R354_EXISTING_OWNER_INSTALLATION_OR_SENSOR_REFUSED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_ALREADY_ADMIN_HOSTED_RUNNER_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R354_PREBUILD_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R354_FRESH_DISPOSABLE_WINDOWS_GUEST_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build unmodified real production Setup PE and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R354_REAL_PRODUCT_SETUP_BUILD_FAILED' + } + - name: Run exact genuine deferred cleanup process only on disposable profile + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R354-OriginalDeferredCleanup-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -Mode '${{ matrix.mode }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){ + throw 'R354_ORIGINAL_COMPILED_CLEANUP_GUEST_FAILED' + } + - name: Retain real original Setup cleanup typed receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R354-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R354_*/R354_ORIGINAL_DEFERRED_CLEANUP.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r355-compiled-directory-pins.yml b/.github/workflows/audit-r355-compiled-directory-pins.yml new file mode 100644 index 00000000..2027aa1d --- /dev/null +++ b/.github/workflows/audit-r355-compiled-directory-pins.yml @@ -0,0 +1,100 @@ +name: R355 compiled Setup directory identity pins (disposable Windows) +on: + push: + branches: [audit/r355-deferred-cleanup-directory-pins] + paths: + - build/Test-R355-CompiledDirectoryPin-Native.ps1 + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Build.ps1 + - .github/workflows/audit-r355-compiled-directory-pins.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + real-deferred-cleanup: + name: R355 exact native lease ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [NativeLease] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-hosted or preexisting real product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R354_REFUSE_WRONG_SOURCE_OR_NONDISPOSABLE_RUNNER' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R354_EXISTING_OWNER_INSTALLATION_OR_SENSOR_REFUSED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_ALREADY_ADMIN_HOSTED_RUNNER_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R354_PREBUILD_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R354_FRESH_DISPOSABLE_WINDOWS_GUEST_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build unmodified real production Setup PE and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R354_REAL_PRODUCT_SETUP_BUILD_FAILED' + } + - name: Run compiled Setup native directory pin and independent rename actor on disposable runner + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R355-CompiledDirectoryPin-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){ + throw 'R355_NATIVE_COMPRESSED_PE_PIN_GUEST_FAILED' + } + - name: Retain real original Setup cleanup typed receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R355-native-lease-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R355_DIRECTORY_PIN_*/R355_PRODUCTION_PIN_RECEIPT.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r355-original-cleanup-concurrent-rename.yml b/.github/workflows/audit-r355-original-cleanup-concurrent-rename.yml new file mode 100644 index 00000000..b3e15040 --- /dev/null +++ b/.github/workflows/audit-r355-original-cleanup-concurrent-rename.yml @@ -0,0 +1,102 @@ +name: R355 original compiled Setup cleanup vs independent live root rename (disposable Windows) +on: + push: + branches: [audit/r355-deferred-cleanup-directory-pins] + paths: + - build/Test-R355-RealCleanupConcurrentRename-Native.ps1 + - build/Test-R355-RealCleanupRenameActor-Native.ps1 + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Build.ps1 + - .github/workflows/audit-r355-original-cleanup-concurrent-rename.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + real-deferred-cleanup: + name: R355 live original ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [LiveRace] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-hosted or preexisting real product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R354_REFUSE_WRONG_SOURCE_OR_NONDISPOSABLE_RUNNER' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R354_EXISTING_OWNER_INSTALLATION_OR_SENSOR_REFUSED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_ALREADY_ADMIN_HOSTED_RUNNER_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R354_PREBUILD_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R354_FRESH_DISPOSABLE_WINDOWS_GUEST_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build unmodified real production Setup PE and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R354_REAL_PRODUCT_SETUP_BUILD_FAILED' + } + - name: Run original compiled Cleanup process with separate native live rename actor + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R355-RealCleanupConcurrentRename-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -Mode '${{ matrix.mode }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){ + throw 'R355_FULL_ORIGINAL_LIVE_RENAME_CLEANUP_GUEST_FAILED' + } + - name: Retain real original Setup cleanup typed receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R355-full-live-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R354_*/R354_ORIGINAL_DEFERRED_CLEANUP.json + ${{ runner.temp }}/R354_*/R355_LIVE_RENAME_ACTOR.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r356-handle-bound-directory-disposition.yml b/.github/workflows/audit-r356-handle-bound-directory-disposition.yml new file mode 100644 index 00000000..cb12fcbf --- /dev/null +++ b/.github/workflows/audit-r356-handle-bound-directory-disposition.yml @@ -0,0 +1,67 @@ +name: R356 native handle-bound directory deletion feasibility (disposable Windows) +on: + push: + branches: [audit/r356-handle-bound-directory-disposition] + paths: + - build/Test-R356-NativePinnedDirectoryDisposition.ps1 + - build/Test-R356-RenameActor-Native.ps1 + - .github/workflows/audit-r356-handle-bound-directory-disposition.yml + workflow_dispatch: +permissions: + contents: read +jobs: + native-handle-disposition: + name: R356 handle disposition ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 18 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout pinned source without retaining credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-disposable runner or mismatched source + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R356_GUEST_OR_SHA_GUARD_FAILED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R356_EXPECT_HOSTED_ADMIN_RUNNER' + } + 'R356_DISPOSABLE_RUNNER_ONLY=PASS' + - name: Run independent Windows process rename and pinned handle disposition + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R356-NativePinnedDirectoryDisposition.ps1).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){ + throw 'R356_HANDLE_DISPOSITION_NATIVE_PROTOTYPE_FAILED' + } + - name: Retain typed original Windows native result, even on failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R356-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R356_*/R356_DIRECTORY_DISPOSITION_NATIVE.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r356-original-setup-directory-handle-regression.yml b/.github/workflows/audit-r356-original-setup-directory-handle-regression.yml new file mode 100644 index 00000000..1b1b2ba3 --- /dev/null +++ b/.github/workflows/audit-r356-original-setup-directory-handle-regression.yml @@ -0,0 +1,105 @@ +name: R356 original PE Setup deletion by pinned handles (disposable Windows) +on: + push: + branches: [audit/r356-handle-bound-directory-disposition] + paths: + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 + - build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 + - build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 + - .github/workflows/audit-r356-original-setup-directory-handle-regression.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-setup: + name: R356 original Setup ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [Nominal, RootJunction, ChildJunction, LiveRace] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout immutable head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-disposable runner and preexisting installed product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R356_ONLY_EXACT_DISPOSABLE_WINDOWS_GUEST' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R356_REJECT_PREEXISTING_USER_INSTALLATION_OR_SCHEDULED_TASK' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R356_RUNNER_ALREADY_ADMIN_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R356_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R356_HOSTED_PREBUILD_SANDBOX_GUARD_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build modified private Setup and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R356_PRIVATE_SETUP_BUILD_FAILED' + } + - name: Run actual production-source Setup generated deferred cleanup + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $mode='${{ matrix.mode }}' + $source=(Resolve-Path .).Path + $script=if($mode -ceq 'LiveRace'){ + (Resolve-Path build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1).Path + }else{ + (Resolve-Path build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1).Path + } + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -Mode $mode -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R356_ORIGINAL_NATIVE_DEFERRED_CLEANUP_REGRESSION_FAILED'} + - name: Retain SHA-auditable original PE guest receipts on success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R356-prod-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R354_*/R354_ORIGINAL_DEFERRED_CLEANUP.json + ${{ runner.temp }}/R354_*/R355_LIVE_RENAME_ACTOR.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69d55ffb..e5a97799 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,81 @@ jobs: shell: pwsh run: ./build/Build.ps1 + - name: R322 embedded backend ZIP archive/hostile entries (native .NET Framework) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-BackendZip-R322.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R322 backend ZIP / PE-resource hostile-archive regression failed.' } + - name: R323 owned-shortcut-only deletion preserves unrelated .lnk files (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutOwnership-R323.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R323 foreign-shortcut deletion guard regressed.' } + - name: R324 reject foreign existing shortcuts before fresh install writes + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutCreation-R324.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R324 shortcut ownership-before-Save regression failed.' } + - name: R324 SHA-pinned ZIP integrity preflight before backend mutation (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-BackendArchiveHash-R324.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R324 archive SHA integrity and old backend preservation regression failed.' } + - name: R326 exact Main CreateProcess without ShellExecute and arguments (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R326-DirectMainProcess-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R326 pinned Main direct CreateProcess native security tests failed.' } + - name: R327 canonical profile Temp instead of mutable TEMP/TMP (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R327-CanonicalSensorTemp-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R327 canonical sensor Temp, reparse and environment-negative tests failed.' } + - name: R328 protected Helper post-preflight log/result boundary (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R328-HelperLogBoundary-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R328 Sensor Helper user log/result reparse boundary checks failed.' } + - name: R329 pinned Main blocks user-profile ancestor rename (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R329-PinnedMainAncestorRename-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R329 same-process and cross-process pinned Main ancestor rename regression failed.' } + - name: R330 native synchronous directory signal fallback only kills exact owned worker (6 tests) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R330-NativeStageDeleteObserver-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R330 synthetic exact-PID NativeKnownStageDelete observer regression failed.' } + - name: R331 no runner-controlled diagnostic writes in shipped Setup PE (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R331 fail-closed Setup minus hosted diagnostic sink native regression failed.' } + + - name: R332 canonical protected sensor reuse, junction and health-state bounds (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R332-SensorReuseBoundary-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R332 protected sensor reuse boundary native negative checks failed.' } + + - name: R333 exact shortcut name occupancy, dangling reparse and foreign owner (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R333-ShortcutNameOccupied-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R333 compiled Setup shortcut occupancy negative test failed.' } + + - name: R334 three remaining Setup command sink trust contracts (compiled native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R334-CommandSinkContracts-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R334 no-UAC three-process-command-sink adversarial contract regression failed.' } + + - name: R335 privileged user log/result sink pinned handle boundary (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R335-AtomicUserLogSink-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R335 compiled privileged Helper scoped handle guard regressions failed.' } + - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 @@ -106,6 +181,26 @@ jobs: $glass = Get-Content ./artifacts/ci-visual/glassproof/GLASS_PROOF.json -Raw | ConvertFrom-Json if ($glass.Status -ne 'PASS' -or $glass.Tests -ne 196) { throw '49-theme alpha contrast foreground proof did not PASS.' } + - name: R272 semantic thermal warnings visible as red ink in Glass on all 49 themes + shell: pwsh + run: | + ./build/Test-GlassThermal-R272.ps1 -MainPath ./build/_out/App/TaskbarMonitorEnhanced.exe -OutDir ./artifacts/ci-thermal-r272 + $r = Get-Content ./artifacts/ci-thermal-r272/R272_ACCEPTANCE.json -Raw | ConvertFrom-Json + if ($r.Status -cne 'PASS' -or $r.ThemePairs -ne 98 -or $r.AlertPixelMinimum -lt 40 -or $r.ColdPixelMaximum -ne 0) { + throw 'R272 threshold-to-red foreground Glass semantic contract failed.' + } + - name: R244 nine-page Settings UI overlap and Glass mode renderer guard + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-SettingsUi-R244.ps1 -MainPath ./build/_out/App/TaskbarMonitorEnhanced.exe -OutDir ./artifacts/ci-settings-r244 + $s = Get-Content ./artifacts/ci-settings-r244/R244_SETTINGS_UI.json -Raw | ConvertFrom-Json + if($s.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or + $s.SettingsPages -ne 9 -or $s.SiblingOverlapCount -ne 0 -or + $s.GlassStatusSeparate -ne $true -or $s.GlassTrackBackAlpha -ne 255) { + throw 'R244.Settings UI glass switch overlap/layout regression' + } + - name: Portable runtime contract probes (5 suites; installed host checks separate) shell: pwsh run: | @@ -123,7 +218,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference='Stop' - ./build/Verify-Release-Metadata-R155.ps1 -SelfTest + ./build/Verify-Release-Metadata-R273.ps1 -SelfTest if ($LASTEXITCODE -ne 0) {throw 'R136 release version, 5 output SHA and SBOM source gates FAILED'} - name: Attest binary build provenance @@ -156,6 +251,10 @@ jobs: artifacts/ci-visual/SUMMARY.json artifacts/ci-visual/chartglassproof/CHART_GLASS_PROOF.json artifacts/ci-visual/glassproof/GLASS_PROOF.json + artifacts/ci-thermal-r272/R272_ACCEPTANCE.json + artifacts/ci-thermal-r272/real-thermal-pixels/THERMAL_GLASS_PROOF.json + artifacts/ci-settings-r244/R244_SETTINGS_UI.json + artifacts/ci-settings-r244/R244_GLASS_SWITCH_ON.png artifacts/ci-runtime/SUMMARY.json artifacts/BUILD_MANIFEST.json artifacts/SBOM.spdx.json @@ -168,3 +267,266 @@ jobs: - name: Print build manifest shell: pwsh run: Get-Content ./artifacts/BUILD_MANIFEST.json + + r324-fresh-shortcut: + name: R324 genuine fresh Windows shortcut negative ${{ matrix.host }} + needs: build + runs-on: ${{ matrix.os }} + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + permissions: + contents: read + steps: + - name: Checkout exact disposable test source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Download exact private build + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v8 + with: + name: build-evidence + path: r324_built_private + - name: Original Setup must refuse foreign Start shortcut before any app root write + shell: powershell + env: + TBME_R324_RUN_FRESH_HOST_NEGATIVE: 'true' + R324_HOST_LABEL: ${{ matrix.host }} + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA) {throw 'R324_GUEST_SOURCE_NOT_PINNED'} + $files=@(Get-ChildItem r324_built_private -Recurse -File) + $mf=@($files|Where-Object Name -eq 'BUILD_MANIFEST.json') + $sx=@($files|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $mx=@($files|Where-Object Name -eq 'TaskbarMonitorEnhanced.exe') + if($mf.Count -ne 1 -or $sx.Count -ne 1 -or $mx.Count -ne 1){throw 'R324_UNEXPECTED_CANDIDATE_FILES'} + $manifest=Get-Content -LiteralPath $mf[0].FullName -Raw|ConvertFrom-Json + $sm=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $mm=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced.exe') + if($sm.Count -ne 1 -or $mm.Count -ne 1 -or + (Get-FileHash $sx[0].FullName -Algorithm SHA256).Hash -cne [string]$sm[0].SHA256 -or + (Get-FileHash $mx[0].FullName -Algorithm SHA256).Hash -cne [string]$mm[0].SHA256){ + throw 'R324_CANDIDATE_BINARY_DIGEST_MISMATCH' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutCreation-R324.ps1 -SetupPath $sx[0].FullName -MainPath $mx[0].FullName + if($LASTEXITCODE -ne 0){throw 'R324_REAL_SETUP_NEGATIVE_FAILED'} + $proof=Join-Path $env:RUNNER_TEMP ('R324_'+$env:R324_HOST_LABEL+'_'+$env:GITHUB_RUN_ID+'\R324_HOSTED_SHORTCUT_RECEIPT.json') + $r=Get-Content -LiteralPath $proof -Raw|ConvertFrom-Json + if($r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne $env:R324_HOST_LABEL -or + $r.Status -cne 'PASS_REAL_FRESH_SETUP_REFUSES_FOREIGN_START_SHORTCUT_BEFORE_APPROOT' -or + !$r.UserAppRootAbsentBeforeAndAfter -or !$r.SameStartShortcutSHA256Preserved -or + !$r.UnownedLinkRemovedAfterWitness -or $r.NativeReflectionChecksPassed -ne 8){ + throw 'R324_TYPED_RECEIPT_CONTRACT_FAILED' + } + - name: R331 real Setup refuses environment-spoofed diagnostic writes on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R331_REFUSE_NON_DISPOSABLE_HOST_OR_SOURCE' + } + $setups=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifests=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setups.Count -ne 1 -or $manifests.Count -ne 1){throw 'R331_EXACT_PRIVATE_BIN_NOT_UNIQUE'} + $buildManifest=Get-Content $manifests[0].FullName -Raw|ConvertFrom-Json + $setupEntry=@($buildManifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($setupEntry.Count -ne 1 -or + (Get-FileHash $setups[0].FullName -Algorithm SHA256).Hash -cne $setupEntry[0].SHA256){ + throw 'R331_HOSTED_SETUP_BINARY_NOT_MANIFEST_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 -SetupPath $setups[0].FullName -HostedFresh -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R331_REAL_NEGATIVE_GUEST_TEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R331_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R331_HOSTED_DIAGNOSTIC_REMOVAL_RECEIPT.json') + if(!(Test-Path $receipt -PathType Leaf)){throw 'R331_HOSTED_TYPED_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R331' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne $setupEntry[0].SHA256 -or + $r.Status -cne 'PASS_FRESH_REAL_SETUP_FAIL_CLOSED_WITHOUT_HOSTED_DIAGNOSTIC_WRITE' -or + $r.RealSetupExecuted -ne $true -or $r.SetupExit -ne 34 -or + $r.DiagnosticOutputAbsent -ne $true -or $r.CanaryPreserved -ne $true -or + $r.InstalledAppUntouched -ne $true -or $r.OwnerMachineTouched -ne $false -or + $r.MainWasNotLaunched -ne $true -or $r.ElevatedUACNotInvoked -ne $true -or + $r.SecurityFinalAccepted -ne $false -or @($r.Checks).Count -ne 12){ + throw 'R331_HOSTED_NEGATIVE_RECEIPT_IDENTITY_FAILED' + } + - name: Preserve bounded raw R331 guest diagnostic-negative receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R331-diag-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R331_${{ matrix.host }}_${{ github.run_id }}/R331_HOSTED_DIAGNOSTIC_REMOVAL_RECEIPT.json + + - name: R332 real compiled Setup rejects ProgramFiles and user-health reparse on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R332_NOT_THE_DISPOSABLE_EXACT_GUEST' + } + $setup=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $mf=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setup.Count -ne 1 -or $mf.Count -ne 1){throw 'R332_CANDIDATE_AND_MANIFEST_NOT_UNIQUE'} + $manifest=Get-Content $mf[0].FullName -Raw|ConvertFrom-Json + $m=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($m.Count -ne 1 -or (Get-FileHash $setup[0].FullName -Algorithm SHA256).Hash -cne [string]$m[0].SHA256){ + throw 'R332_SETUP_PRIVATE_BINARY_NOT_MANIFEST_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R332-SensorReuseBoundary-Native.ps1 -SetupPath $setup[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R332_PROTECTED_REUSE_NEGATIVE_TEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R332_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R332_SENSOR_REUSE_BOUNDARY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R332_TYPED_GUEST_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw |ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R332' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne [string]$m[0].SHA256 -or + $r.Status -cne 'PASS_CANONICAL_SENSOR_REUSE_JUNCTION_FAIL_CLOSED' -or @($r.Checks).Count -ne 18 -or + $r.RealCompiledNetFrameworkSetupLoaded -ne $true -or $r.ProgramFilesRealRootModified -ne $false -or + $r.ActualSensorHelperElevated -ne $false -or $r.OwnerInstalledProgramChanged -ne $false -or + $r.ExternalCanaryPreserved -ne $true -or $r.AtomicReparseRaceProven -ne $false -or + $r.SecurityFinalAccepted -ne $false){ + throw 'R332_TYPED_GUEST_IDENTITY_OR_SCOPE_INVALID' + } + - name: Preserve bounded R332 reparse-negative guest receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R332-reuse-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R332_${{ matrix.host }}_${{ github.run_id }}/R332_SENSOR_REUSE_BOUNDARY_RECEIPT.json + + - name: R333 compiled Setup preserves all occupied foreign Shortcut names on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R333_NOT_EXACT_DISPOSABLE_GUEST' + } + $setups=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifests=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setups.Count -ne 1 -or $manifests.Count -ne 1){throw 'R333_PRIVATE_SETUP_OR_MANIFEST_AMBIGUOUS'} + $manifest=Get-Content $manifests[0].FullName -Raw|ConvertFrom-Json + $identity=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($identity.Count -ne 1 -or (Get-FileHash $setups[0].FullName -Algorithm SHA256).Hash -cne [string]$identity[0].SHA256){ + throw 'R333_PRIVATE_SETUP_EXACT_SHA_NOT_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R333-ShortcutNameOccupied-Native.ps1 -SetupPath $setups[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R333_NATIVE_GUEST_NEGATIVE_TEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R333_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R333_SHORTCUT_OCCUPANCY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R333_GUEST_RAW_TYPED_RECEIPT_NOT_CREATED'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R333' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne [string]$identity[0].SHA256 -or + $r.Status -cne 'PASS_EXACT_SHORTCUT_OCCUPANCY_INCLUDING_DANGLING_REPARSE' -or + @($r.Checks).Count -ne 22 -or $r.RealCompiledSetupLoaded -ne $true -or + $r.RealMainLaunched -ne $false -or $r.ForeignCanaryPreserved -ne $true -or + $r.ActualUacInvoked -ne $false -or $r.OriginalOwnerHostTouched -ne $false -or + $r.AtomicPathRaceProven -ne $false -or $r.SecurityFinalAccepted -ne $false){ + throw 'R333_GUEST_EVIDENCE_CONTRACT_NOT_ACCEPTED' + } + - name: Preserve bounded R333 shortcut occupancy guest evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R333-shortcut-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R333_${{ matrix.host }}_${{ github.run_id }}/R333_SHORTCUT_OCCUPANCY_RECEIPT.json + + - name: R334 inspect hostile process command construction on isolated Windows ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R334_REFUSE_NON_DISPOSABLE_OR_UNPINNED_WINDOWS' + } + $setups=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifests=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setups.Count -ne 1 -or $manifests.Count -ne 1){throw 'R334_PRIVATE_SETUP_OR_BUILD_MANIFEST_AMBIGUOUS'} + $manifest=Get-Content $manifests[0].FullName -Raw|ConvertFrom-Json + $identity=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($identity.Count -ne 1 -or + (Get-FileHash $setups[0].FullName -Algorithm SHA256).Hash -cne [string]$identity[0].SHA256){ + throw 'R334_PRIVATE_COMPILED_SETUP_NOT_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R334-CommandSinkContracts-Native.ps1 -SetupPath $setups[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R334_COMMAND_FACTORIES_NATIVE_GUEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R334_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R334_COMMAND_SINK_TRUST_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R334_RAW_TYPED_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R334' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne [string]$identity[0].SHA256 -or + $r.Status -cne 'PASS_THREE_PROCESS_COMMAND_SINK_TRUST_CONTRACTS' -or + @($r.Checks).Count -ne 24 -or $r.CompiledSetupReflected -ne $true -or + $r.HelperAndCleanupInputsOnlyInEncodedData -ne $true -or + $r.ShaPinnedMainNoShell -ne $true -or $r.NativeUacWasNotInvoked -ne $true -or + $r.NoCleanupOrMainProcessLaunched -ne $true -or $r.WholeSecurityAccepted -ne $false){ + throw 'R334_GUEST_COMMAND_BOUNDARY_TYPED_RECEIPT_INVALID' + } + - name: Preserve bounded raw R334 command trust guest receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R334-command-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R334_${{ matrix.host }}_${{ github.run_id }}/R334_COMMAND_SINK_TRUST_RECEIPT.json + + - name: R335 real Setup embeds exact native no-reparse handle guard on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R335_NATIVE_REFUSE_NON_HOSTED_OR_CHANGED_SOURCE' + } + $setup=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifest=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setup.Count -ne 1 -or $manifest.Count -ne 1){throw 'R335_BUILD_PROOF_AMBIGUOUS'} + $m=Get-Content $manifest[0].FullName -Raw|ConvertFrom-Json + $target=@($m.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($target.Count -ne 1 -or + (Get-FileHash $setup[0].FullName -Algorithm SHA256).Hash -cne [string]$target[0].SHA256){ + throw 'R335_PRIVATE_SETUP_NOT_BUILD_MANIFEST_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R335-AtomicUserLogSink-Native.ps1 -SetupPath $setup[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R335_REAL_PRIVATE_SETUP_HANDLE_GUEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R335_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R335_ATOMIC_LOG_HANDLE_PROBE.json') + if(!(Test-Path $receipt -PathType Leaf)){throw 'R335_EXACT_TYPED_GUEST_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R335' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.Status -cne 'PASS_SCOPED_NONDELETE_HANDLE_LOGS_APPROOT_AND_FILE' -or + @($r.Checks).Count -ne 23 -or + $r.RealElevatedHelperLaunched -ne $false -or + $r.ActualProgramFilesModified -ne $false -or + $r.AtomicCrossUserUacProven -ne $false -or + $r.ForeignCanaryPreserved -ne $true -or + $r.SecurityFinalAccepted -ne $false){ + throw 'R335_HOSTED_GUEST_RECEIPT_CONTRACT_FAILED' + } + - name: Preserve bounded R335 native user-log-handle proof + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R335-user-log-handle-${{ matrix.host }}-${{ github.sha }} + retention-days: 7 + if-no-files-found: error + path: ${{ runner.temp }}/R335_${{ matrix.host }}_${{ github.run_id }}/R335_ATOMIC_LOG_HANDLE_PROBE.json + + - name: Preserve bounded raw hosted negative receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R324-fresh-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R324_${{ matrix.host }}_${{ github.run_id }}/R324_HOSTED_SHORTCUT_RECEIPT.json diff --git a/.github/workflows/release-v172-acceptance.yml b/.github/workflows/release-v172-acceptance.yml deleted file mode 100644 index e741e923..00000000 --- a/.github/workflows/release-v172-acceptance.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: v1.7.2 - Exact Upgrade and Native Acceptance -on: - push: - branches: [release/r155-v172-upgrade, main] - workflow_dispatch: -permissions: - contents: read -jobs: - build-candidate: - runs-on: windows-latest - timeout-minutes: 20 - steps: - - name: Exact source checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Parser and versioned reproducible build - shell: pwsh - run: | - $ErrorActionPreference='Stop' - if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R155 source drift'} - foreach($p in @('build/Test-Upgrade171-R155.ps1','build/Test-UpgradeContract-R155.ps1','build/Test-Native-Presentation-R155.ps1')){ - $t=$null;$e=$null - [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) - if(@($e).Count){throw ('R155 parser '+$p)} - } - ./build/Build.ps1 - ./build/Generate-Sbom.ps1 - ./build/Verify-Release-Metadata-R155.ps1 -SelfTest - if($LASTEXITCODE -ne 0){throw 'R155 version/manifest/SPDX gate'} - $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.2.exe' - $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.2';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} - [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) - - name: Actual compiled control, geometry, visibility and upgrade contracts - shell: powershell - run: | - $ErrorActionPreference='Stop' - $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/APPEARANCE') - ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/GEOMETRY') - ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/VISIBILITY') - ./build/Test-UpgradeContract-R155.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.2.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/UPGRADE') - - name: Preserve one exact candidate installer, not a release - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: R155-candidate - if-no-files-found: error - retention-days: 14 - path: | - build/_out/CANDIDATE.json - build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.2.exe - - name: Preserve model receipts and binary manifest - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: R155-compiled-contracts - if-no-files-found: warn - retention-days: 21 - path: | - ${{ runner.temp }}/R155_MODELS/** - artifacts/BUILD_MANIFEST.json - artifacts/SBOM.spdx.json - upgrade-and-native: - needs: build-candidate - runs-on: windows-latest - timeout-minutes: 24 - name: Real v1.7.1 upgrade and four native modes (${{ matrix.host }}) - strategy: - fail-fast: false - matrix: - host: [A, B] - steps: - - name: Fresh independent Windows source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Transfer exact previously compiled Setup bytes - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: R155-candidate - path: build/_out - - name: Genuine direct and inherited v1.7.1 protected upgrade - shell: powershell - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference='Stop' - ./build/Test-Upgrade171-R155.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R155_UPGRADE_${{ matrix.host }}/R155_UPGRADE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_GENUINE_171_TO_172_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R155 actual updater acceptance failed'} - - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes - shell: powershell - run: | - $ErrorActionPreference='Stop' - ./build/Test-Native-Presentation-R155.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R155_${{ matrix.host }}/R155_NATIVE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R155 native four-mode evidence not accepted'} - - name: Preserve raw upgrade receipts and physical native pixels on success or failure - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: R155-native-upgrade-${{ matrix.host }} - if-no-files-found: warn - retention-days: 21 - path: | - ${{ runner.temp }}/TBME_R155_UPGRADE_${{ matrix.host }}/R155_UPGRADE.json - ${{ runner.temp }}/TBME_R155_${{ matrix.host }}/** diff --git a/.github/workflows/release-v175-acceptance.yml b/.github/workflows/release-v175-acceptance.yml new file mode 100644 index 00000000..0d9fa7ad --- /dev/null +++ b/.github/workflows/release-v175-acceptance.yml @@ -0,0 +1,169 @@ +name: v1.7.5 - Full protected v1.7.4 upgrade and native acceptance +on: + push: + branches: [release/r265-v175-sensor-crossuser-safety] + workflow_dispatch: +permissions: + contents: read +jobs: + build-candidate: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Exact source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Parser and versioned reproducible build + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R265 source drift'} + foreach($p in @('build/Test-Upgrade174-R265.ps1','build/Test-UpgradeContract-R265.ps1','build/Test-Native-Presentation-R265.ps1')){ + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) + if(@($e).Count){throw ('R265 parser '+$p)} + } + ./build/Build.ps1 + ./build/Generate-Sbom.ps1 + ./build/Verify-Release-Metadata-R265.ps1 -SelfTest + if($LASTEXITCODE -ne 0){throw 'R265 version/manifest/SPDX gate'} + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe' + $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.5';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} + [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + - name: Actual compiled control, geometry, visibility and upgrade contracts + shell: powershell + run: | + $ErrorActionPreference='Stop' + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/APPEARANCE') + ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/SETTINGS') + $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R265_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json + if($ui.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or $ui.SettingsPages -ne 9 -or $ui.SiblingOverlapCount -ne 0){throw 'R244_NATIVE_SETTINGS_UI_LAYOUT_COLLISION'} + ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/GEOMETRY') + ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/VISIBILITY') + ./build/Test-UpgradeContract-R265.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/UPGRADE') + - name: Preserve one exact candidate installer, not a release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R265-candidate + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe + - name: Preserve model receipts and binary manifest + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R265-compiled-contracts + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R265_MODELS/** + artifacts/BUILD_MANIFEST.json + artifacts/SBOM.spdx.json + upgrade-and-native: + needs: build-candidate + runs-on: windows-latest + timeout-minutes: 24 + name: Real official 1.7.4 to 1.7.5 upgrade and native modes (${{ matrix.host }}) + strategy: + fail-fast: false + matrix: + host: [A, B] + steps: + - name: Fresh independent Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Transfer exact previously compiled Setup bytes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R265-candidate + path: build/_out + - name: Genuine official v1.7.4 upgrade preserving user data + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-Upgrade174-R265.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R265_UPGRADE_${{ matrix.host }}/R265_UPGRADE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_GENUINE_174_TO_175_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R265 actual updater acceptance failed'} + - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-Native-Presentation-R265.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R265_${{ matrix.host }}/R265_NATIVE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R265 native four-mode evidence not accepted'} + - name: Preserve raw upgrade receipts and physical native pixels on success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R265-native-upgrade-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R265_UPGRADE_${{ matrix.host }}/R265_UPGRADE.json + ${{ runner.temp }}/TBME_R265_${{ matrix.host }}/** + + fresh-install-maintenance: + needs: build-candidate + name: "R265 truly fresh, repeated maintenance, legacy duplicate refusal ${{ matrix.host }}" + runs-on: ${{ matrix.system }} + timeout-minutes: 23 + strategy: + fail-fast: false + matrix: + include: + - host: A + system: windows-2025 + - host: B + system: windows-2022 + steps: + - name: Fresh disposable Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Download compiled exact candidate, not public Setup + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R265-candidate + path: build/_out + - name: Prove no root-source duplication, two maintenance calls and conflict refusal + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R265-FreshSameVersion-Disposable.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R265_${{ matrix.host }}/R265_FRESH.json' + if(!(Test-Path $receipt)){throw 'R265_FRESH_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_GUEST_FRESH_R265_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.SingleManagedSourceWithoutRootDuplicate -ne $true -or + $r.FreshInstallExit -ne 0 -or + $r.FirstSameVersionMaintenanceExit -ne 0 -or + $r.NegativeProgramFilesCollisionExit -ne 34 -or + $r.SecondSameVersionMaintenanceExit -ne 0 -or + $r.ProtectedCanaryAndConfigPreserved -ne $true -or + $r.PublicSetupChanged -ne $false -or + $r.PersonalComputerTouched -ne $false -or + $r.RealHKLMRegistryOrCrossUserAcceptance -ne $false){ + throw 'R265_REAL_FRESH_INSTALL_OR_DATA_SCOPE_NOT_PASS' + } + 'R265_REAL_TWO_HOST_FRESH_AND_REPEATED_MAINTENANCE=PASS' + # The job invokes native commands in the child test; only after all + # receipt assertions passed reset stale native LASTEXITCODE. + $global:LASTEXITCODE=0 + - name: Save bounded disposable fresh-only receipts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R265-fresh-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R265_${{ matrix.host }}/R265_FRESH.json diff --git a/.github/workflows/release-v176-acceptance.yml b/.github/workflows/release-v176-acceptance.yml new file mode 100644 index 00000000..88da42c4 --- /dev/null +++ b/.github/workflows/release-v176-acceptance.yml @@ -0,0 +1,171 @@ +name: v1.7.6 - Full protected v1.7.5 upgrade and native acceptance +on: + push: + branches: [fix/v1.7.6-glass-thermal-alert-r272] + workflow_dispatch: +permissions: + contents: read +jobs: + build-candidate: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Exact source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Parser and versioned reproducible build + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R273 source drift'} + foreach($p in @('build/Test-Upgrade175-R273.ps1','build/Test-UpgradeContract-R273.ps1','build/Test-Native-Presentation-R273.ps1')){ + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) + if(@($e).Count){throw ('R273 parser '+$p)} + } + ./build/Build.ps1 + ./build/Generate-Sbom.ps1 + ./build/Verify-Release-Metadata-R273.ps1 -SelfTest + ./build/Test-GlassThermal-R272.ps1 -MainPath ./build/_out/App/TaskbarMonitorEnhanced.exe -OutDir ./artifacts/r273-thermal-pixels + if($LASTEXITCODE -ne 0){throw 'R273 version/manifest/SPDX gate'} + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe' + $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.6';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} + [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + - name: Actual compiled control, geometry, visibility and upgrade contracts + shell: powershell + run: | + $ErrorActionPreference='Stop' + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/APPEARANCE') + ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/SETTINGS') + $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R273_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json + if($ui.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or $ui.SettingsPages -ne 9 -or $ui.SiblingOverlapCount -ne 0){throw 'R244_NATIVE_SETTINGS_UI_LAYOUT_COLLISION'} + ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/GEOMETRY') + ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/VISIBILITY') + ./build/Test-UpgradeContract-R273.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/UPGRADE') + - name: Preserve one exact candidate installer, not a release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-candidate + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe + - name: Preserve model receipts and binary manifest + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-compiled-contracts + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R273_MODELS/** + artifacts/BUILD_MANIFEST.json + artifacts/SBOM.spdx.json + artifacts/r273-thermal-pixels/R272_ACCEPTANCE.json + upgrade-and-native: + needs: build-candidate + runs-on: windows-latest + timeout-minutes: 24 + name: Real official 1.7.5 to 1.7.6 upgrade and native modes (${{ matrix.host }}) + strategy: + fail-fast: false + matrix: + host: [A, B] + steps: + - name: Fresh independent Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Transfer exact previously compiled Setup bytes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R273-candidate + path: build/_out + - name: Genuine official v1.7.5 upgrade preserving user data + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-Upgrade175-R273.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R273_UPGRADE_${{ matrix.host }}/R273_UPGRADE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_GENUINE_175_TO_176_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R273 actual updater acceptance failed'} + - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-Native-Presentation-R273.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R273_${{ matrix.host }}/R273_NATIVE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R273 native four-mode evidence not accepted'} + - name: Preserve raw upgrade receipts and physical native pixels on success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-native-upgrade-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R273_UPGRADE_${{ matrix.host }}/R273_UPGRADE.json + ${{ runner.temp }}/TBME_R273_${{ matrix.host }}/** + + fresh-install-maintenance: + needs: build-candidate + name: "R273 truly fresh, repeated maintenance, legacy duplicate refusal ${{ matrix.host }}" + runs-on: ${{ matrix.system }} + timeout-minutes: 23 + strategy: + fail-fast: false + matrix: + include: + - host: A + system: windows-2025 + - host: B + system: windows-2022 + steps: + - name: Fresh disposable Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Download compiled exact candidate, not public Setup + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R273-candidate + path: build/_out + - name: Prove no root-source duplication, two maintenance calls and conflict refusal + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R273-FreshSameVersion-Disposable.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R273_${{ matrix.host }}/R273_FRESH.json' + if(!(Test-Path $receipt)){throw 'R273_FRESH_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_GUEST_FRESH_R273_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.SingleManagedSourceWithoutRootDuplicate -ne $true -or + $r.FreshInstallExit -ne 0 -or + $r.FirstSameVersionMaintenanceExit -ne 0 -or + $r.NegativeProgramFilesCollisionExit -ne 34 -or + $r.SecondSameVersionMaintenanceExit -ne 0 -or + $r.ProtectedCanaryAndConfigPreserved -ne $true -or + $r.PublicSetupChanged -ne $false -or + $r.PersonalComputerTouched -ne $false -or + $r.RealHKLMRegistryOrCrossUserAcceptance -ne $false){ + throw 'R273_REAL_FRESH_INSTALL_OR_DATA_SCOPE_NOT_PASS' + } + 'R273_REAL_TWO_HOST_FRESH_AND_REPEATED_MAINTENANCE=PASS' + # The job invokes native commands in the child test; only after all + # receipt assertions passed reset stale native LASTEXITCODE. + $global:LASTEXITCODE=0 + - name: Save bounded disposable fresh-only receipts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-fresh-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R273_${{ matrix.host }}/R273_FRESH.json diff --git a/build/Build.ps1 b/build/Build.ps1 index b3ecde8f..2e3625c3 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -92,12 +92,12 @@ $CanonicalTextResources=[ordered]@{ 'TaskbarMonitorSensorSupervisor.cs'=(Join-Path $Root 'src\sensors\TaskbarMonitorSensorSupervisor.cs') 'TBME_Setup_Elevated_Helper.ps1'=(Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1') 'LICENSE'=(Join-Path $Root 'LICENSE') - 'README.md'=(Join-Path $Root 'docs\releases\v1.7.2\INSTALLER_README.txt') + 'README.md'=(Join-Path $Root 'docs\releases\v1.7.8\INSTALLER_README.txt') 'AUTHORS.md'=(Join-Path $Root 'AUTHORS.md') 'COPYRIGHT_AND_ATTRIBUTION.md'=(Join-Path $Root 'COPYRIGHT_AND_ATTRIBUTION.md') 'AI_ASSISTED_DEVELOPMENT.md'=(Join-Path $Root 'AI_ASSISTED_DEVELOPMENT.md') 'THIRD_PARTY_NOTICES.md'=(Join-Path $Root 'THIRD_PARTY_NOTICES.md') - 'RELEASE_NOTES_v1.7.2.md'=(Join-Path $Root 'docs\releases\v1.7.2\RELEASE_NOTES.md') + 'RELEASE_NOTES_v1.7.8.md'=(Join-Path $Root 'docs\releases\v1.7.8\RELEASE_NOTES.md') 'UPSTREAM_REFERENCE_GPL_NOTICE.md'=(Join-Path $Root 'UPSTREAM_REFERENCE_GPL_NOTICE.md') 'TaskbarMonitorEnhanced_Setup.cs'=(Join-Path $Root 'installer\TaskbarMonitorEnhanced_Setup.cs') } @@ -107,7 +107,7 @@ foreach($entry in $CanonicalTextResources.GetEnumerator()){ Write-Host 'TBME_CANONICAL_TEXT_PAYLOAD=PASS' Build (Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj') -$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' if(!(Test-Path -LiteralPath $Setup)){throw 'Setup output missing.'} # SignPath Foundation requires uniform signed-artifact project and ProductVersion metadata. @@ -152,8 +152,8 @@ if([string]$v.Status -ne 'PASS' -or [int]$v.Resources -ne 25 -or $v.PawnIOBundle if($LASTEXITCODE -ne 0){throw 'Application contract suite failed.'} $manifest=[ordered]@{ - Version='1.7.2' - Build='V1_7_2_R155_TASKBAR_FIX' + Version='1.7.8' + Build='V1_7_8_R286_OPTIONAL_TEMP_SOUND' GeneratedUtc=[datetime]::UtcNow.ToString('o') Dependencies=[ordered]@{ LibreHardwareMonitor=[ordered]@{Version=[string]$Lock.dependencies.LibreHardwareMonitor.version;Url=$LhmUrl;SHA256=$LhmSha} diff --git a/build/Package-Signed-Setup.ps1 b/build/Package-Signed-Setup.ps1 index 266d31bd..439d60d0 100644 --- a/build/Package-Signed-Setup.ps1 +++ b/build/Package-Signed-Setup.ps1 @@ -10,10 +10,10 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $UnsignedMain=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' $StageBase=if($ContractFixtureOnly){Join-Path $Root 'artifacts\_r80_contract_only'}else{Join-Path $Root 'artifacts\signed\Setup\TO_SIGN'} -$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' $Proof=Join-Path $StageBase 'EMBEDDED_MAIN_PROOF.json' $Verification=Join-Path $StageBase 'SETUP_VERIFY.json' $CreatedStage=$false diff --git a/build/TaskbarMonitorEnhanced_Setup.csproj b/build/TaskbarMonitorEnhanced_Setup.csproj index 8aa58816..18d6f3b0 100644 --- a/build/TaskbarMonitorEnhanced_Setup.csproj +++ b/build/TaskbarMonitorEnhanced_Setup.csproj @@ -1,5 +1,5 @@ - net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.2_out\Setup\false + net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.8_out\Setup\false _package\TaskbarMonitorEnhanced.exefalse @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/build/Test-AbruptStageRecovery-R282.ps1 b/build/Test-AbruptStageRecovery-R282.ps1 new file mode 100644 index 00000000..f350d347 --- /dev/null +++ b/build/Test-AbruptStageRecovery-R282.ps1 @@ -0,0 +1,305 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('PREPARED','COMMITTING')][string]$Phase +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +Add-Type -AssemblyName System.Windows.Forms +# Irreversible termination is ONLY allowed for the exact child we launch +# on an authenticated disposable GitHub-hosted Windows VM. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + ![Environment]::UserInteractive){throw 'R282_REFUSE_NON_DISPOSABLE_OR_OWNER_HOST'} +$repo='GOD13emad/TaskbarMonitorEnhanced' +$releaseHead='2348791fbde3aff5a18b7df536dba8a66a65d191' +$sha175='8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC' +$sha176='D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0' +$main175='25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' +$main176='0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$uninstall='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R282_'+$HostLabel+'_'+$Phase) +$armed=Join-Path $out 'ARMED.json' +$receipt=Join-Path $out 'R282_ABRUPT_RECOVERY.json' +$childScript=Join-Path $out 'StageChild.ps1' +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $uninstall) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -gt 0){ + throw 'R282_REFUSE_NON_FRESH_HOST_OR_PREEXISTING_PRODUCT' +} +$previousExe=$null;$currentExe=$null;$stageProcess=$null +$phaseNow='START' +$reason='';$passes=@();$terminated=$false;$partialReplacement=$false +function Hash([string]$p){ + if(Test-Path $p -PathType Leaf){return (Get-FileHash $p -Algorithm SHA256).Hash} + return 'MISSING' +} +function Run-Exact([string]$exe,[string]$arguments){ + # Do not use '$args' as a parameter: PowerShell automatically replaces it with + # unbound positional arguments, silently dropping /update /quiet and hanging GUI. + if($arguments -cne '/update /quiet'){throw ('R282_UNEXPECTED_SETUP_ARGUMENT_STRING_'+$arguments)} + # Exact process launch from successful R273 hosted installer acceptance. + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$exe;$si.Arguments=$arguments;$si.UseShellExecute=$false + $si.WorkingDirectory=(Split-Path $exe -Parent) + $p=[Diagnostics.Process]::Start($si) + if(!$p){throw 'R282_SETUP_NOT_STARTED'} + try{ + if(!$p.WaitForExit(190000)){ + $children=@(Get-CimInstance Win32_Process | Where-Object {$_.ParentProcessId -eq $p.Id} | Select-Object -ExpandProperty Name) + $driver=Get-Service PawnIO -ErrorAction SilentlyContinue + $snapshot=[ordered]@{ + FailedPhase=$phaseNow;InstallerPid=$p.Id + InstallerStillRunning=(-not $p.HasExited) + ChildProcessNames=$children + InstalledRootExists=[bool](Test-Path $app) + InstalledMainPresent=[bool](Test-Path (Join-Path $app 'TaskbarMonitorEnhanced.exe')) + InstalledStatePresent=[bool](Test-Path (Join-Path $app 'install_state.json')) + PawnIOServiceState=$(if($driver){[string]$driver.Status}else{'NOT_PRESENT'}) + CanReadInstallLogs=[bool](Test-Path (Join-Path $app 'Logs')) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText((Join-Path $out 'R282_INSTALL_DIAGNOSTIC.json'),($snapshot|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + throw ('R282_SETUP_TIMEOUT_PID_'+$p.Id+'_CHILDREN_'+($children -join ',')) + } + return [int]$p.ExitCode + }finally{$p.Dispose()} +} +function Startup(){ + $p=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if(!$p){return 'ABSENT'} + $v=$p.PSObject.Properties['TaskbarMonitorEnhanced'] + return $(if(!$v){'ABSENT'}else{[string]$v.Value}) +} +# Minimal child script: all source / installer functions are the unchanged, +# SHA-pinned public 1.7.6 code. The child itself is the sole forced termination target. +$childText=@' +param( + [Parameter(Mandatory=$true)][string]$Setup, + [Parameter(Mandatory=$true)][string]$App, + [Parameter(Mandatory=$true)][string]$Signal, + [Parameter(Mandatory=$true)][string]$Phase +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +try{ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC'){throw 'CHILD_REFUSES_PERSONAL_HOST'} + if((Get-FileHash $Setup).Hash -ne 'D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0'){throw 'WRONG_PUBLIC_SETUP'} + if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -ne '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6'){throw 'WRONG_INSTALLED_PREDECESSOR'} + if(Test-Path (Join-Path $App '.tbme-upgrade')){throw 'JOURNAL_ALREADY_PRESENT'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'APP_RUNNING_DURING_DISPOSABLE_TEST'} + Add-Type -TypeDefinition 'using System.IO; public static class R282FileBridge { public static void ReplaceNoBackup(string a,string b){File.Replace(a,b,null);} }' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($Setup)) + $t=$asm.GetType('SetupProgram',$true) + $f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $version='1.7.5';$channel='OFFICIAL_STABLE_V175' + $state=([IO.File]::ReadAllText((Join-Path $App 'install_state.json'))) + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@($version,$channel)) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($state)) + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($Setup) + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$state,$version,$channel)) + if($Phase -ceq 'COMMITTING'){ + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $App '.tbme-upgrade\new') $rel + $live=Join-Path $App $rel + [R282FileBridge]::ReplaceNoBackup($staged,$live) + } + if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -ne '0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8'){throw 'PHYSICAL_PARTIAL_COMMIT_UNVERIFIED'} + } + $report=[ordered]@{Phase=$Phase;ChildProcessId=$PID;CommittedManagedCount=$(if($Phase -eq 'COMMITTING'){2}else{0});ArmUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($Signal,($report|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + while($true){Start-Sleep -Seconds 3} +}catch{ + $errorPath=[IO.Path]::ChangeExtension($Signal,'.error.txt') + [IO.File]::WriteAllText($errorPath,$_.Exception.ToString(),[Text.UTF8Encoding]::new($false)) + throw +} +'@ +$receiptWritten=$false +[IO.Directory]::CreateDirectory($out)|Out-Null +try{ + $phaseNow='DOWNLOAD_OFFICIAL_RELEASES' + $env:GH_PROMPT_DISABLED='1' + & gh release download v1.7.5 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.5.exe -D $out + if($LASTEXITCODE -ne 0){throw 'PRIOR_175_RELEASE_DOWNLOAD_FAILED'} + & gh release download v1.7.6 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.6.exe -D $out + if($LASTEXITCODE -ne 0){throw 'CURRENT_176_RELEASE_DOWNLOAD_FAILED'} + $previousExe=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' + $currentExe=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' + if((Hash $previousExe) -cne $sha175 -or (Hash $currentExe) -cne $sha176){throw 'SIGNED_OFF_PUBLIC_ASSET_SHA_CHANGED'} + $passes+=@('PUBLIC_175_176_ASSETS_PINNED') + $phaseNow='STAGE_EXACT_22_SHA_PINNED_OFFICIAL_V175_RESOURCES_ONLY' + # Previous authentic R273 hosted CI covered genuine fresh 1.7.5 install; + # this R282 isolated crash test needs a byte-exact prior product root WITHOUT + # installing privileged machine drivers or invoking fresh Setup/UAC. + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v175-managed.json') -Raw|ConvertFrom-Json + if($inventory.Version -ne '1.7.5' -or $inventory.ManagedCount -ne 22 -or + $inventory.PublishedSetupSHA256 -cne $sha175 -or $inventory.PublishedMainSHA256 -cne $main175 -or + @($inventory.Rows).Count -ne 22){throw 'R282_PREDECESSOR_22_FILE_PINNING_CHANGED'} + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($previousExe)) + [IO.Directory]::CreateDirectory($app)|Out-Null + foreach($row in $inventory.Rows){ + $rel=[string]$row.Path + if([string]::IsNullOrWhiteSpace($rel) -or [IO.Path]::IsPathRooted($rel) -or + $rel -match '(^|[/\\])\.\.([/\\]|$)' -or $rel -match '[:*?"<>|]'){throw 'R282_UNSAFE_PREDECESSOR_MEMBER'} + $dest=Join-Path $app $rel + $normalized=[IO.Path]::GetFullPath($dest) + if(!$normalized.StartsWith(([IO.Path]::GetFullPath($app).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){throw 'R282_RESOURCE_OUTPUT_OUTSIDE_DISPOSABLE_ROOT'} + [IO.Directory]::CreateDirectory((Split-Path $normalized -Parent))|Out-Null + if(Test-Path $normalized){throw ('R282_DUPLICATE_RESOURCE_'+$rel)} + if($rel -ceq 'Uninstall.exe'){ + [IO.File]::Copy($previousExe,$normalized) + }else{ + $resourceName='Payload.'+[IO.Path]::GetFileName($rel) + $stream=$assembly.GetManifestResourceStream($resourceName) + if(!$stream){throw ('R282_PREDECESSOR_EMBEDDED_RESOURCE_MISSING_'+$resourceName)} + try{ + $target=[IO.File]::Open($normalized,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($target)}finally{$target.Dispose()} + }finally{$stream.Dispose()} + } + if((Hash $normalized) -cne $row.SHA256 -or (Get-Item $normalized).Length -ne $row.Bytes){throw ('R282_OFFICIAL_PREDECESSOR_ROW_IDENTITY_DRIFT_'+$rel)} + } + $backendRoot=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6' + $archive=Join-Path $out 'LHM_096_REPLAY_FROM_OFFICIAL_175_SETUP.zip' + $stream=$assembly.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$stream){throw 'R282_OFFICIAL_SENSOR_LIBRARY_RESOURCE_MISSING'} + try{ + $target=[IO.File]::Open($archive,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($target)}finally{$target.Dispose()} + }finally{$stream.Dispose()} + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Directory]::CreateDirectory($backendRoot)|Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory($archive,$backendRoot) + if((Hash (Join-Path $backendRoot 'LibreHardwareMonitorLib.dll')) -cne '6EBC194316536BA61AF5BE24508AD9FCBB2ECC685E716C12E787C79530F66BF0'){throw 'R282_PINNED_BACKEND_LIBRARY_WRONG'} + $synthetic=[ordered]@{App='Taskbar Monitor Enhanced';Version='1.7.5';PublicVersion='1.7.5' + ProductIdentity='LOCKED';SensorLayerStatus='READY';SensorLayerMode='R282_DISPOSABLE_PRESEEDED_NO_DRIVER' + SensorLayerVersion='0.9.6';MainLaunchMode='NOT_STARTED_DISPOSABLE';R282Fixture=$true} + [IO.File]::WriteAllText((Join-Path $app 'install_state.json'),($synthetic|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + $key=New-Item -Path $uninstall -Force -ErrorAction Stop + Set-ItemProperty -Path $uninstall -Name 'DisplayName' -Value 'Taskbar Monitor Enhanced' + Set-ItemProperty -Path $uninstall -Name 'DisplayVersion' -Value '1.7.5' + Set-ItemProperty -Path $uninstall -Name 'InstallLocation' -Value $app + $key.Dispose() + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main175 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $sha175 -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.5'){throw 'R282_PINNED_PREDECESSOR_FIXTURE_INCOMPLETE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'DISPOSABLE_FIXTURE_STARTED_REAL_PRODUCT'} + $passes+=@('22_OFFICIAL_V175_MANAGED_ROWS_VERIFIED_SHA_FROM_EMBEDDED_RESOURCES','NO_PRIVILEGED_SENSOR_DRIVER_INSTALLED') + $config=Join-Path $app 'config.json' + $backup=Join-Path $app 'config.json.bak' + $userOwned=Join-Path $app 'R282_USER_OWNED_CANARY.txt' + $backend=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll' + $fixture='{"Theme":"Retro Terminal","GlassMode":true,"Opacity":0.0,"EnableTemperatureAlerts":true,"EnableTemperatureNotifications":true,"R282Canary":"MUST_PRESERVE_ABRUPT_STAGE"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"R282Backup":"INDEPENDENT"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($userOwned,'R282 disposable user file remains unchanged',[Text.UTF8Encoding]::new($false)) + $protected=@{} + foreach($path in @($config,$backup,$userOwned,$backend)){ + if(!(Test-Path $path)){throw ('PROTECTED_FILE_MISSING_'+$path)} + $protected[$path]=Hash $path + } + $startupBefore=Startup + $stateBefore=Hash (Join-Path $app 'install_state.json') + $sourceBefore=Hash (Join-Path $app 'Source\TaskbarGlass.cs') + $passes+=@('PREEXISTING_OWNERLIKE_DATA_PINNED') + $phaseNow='STAGE_IN_SEPARATE_CHILD_PROCESS' + [IO.File]::WriteAllText($childScript,$childText,[Text.UTF8Encoding]::new($false)) + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $si.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$childScript+'" -Setup "'+$currentExe+'" -App "'+$app+'" -Signal "'+$armed+'" -Phase '+$Phase + $si.UseShellExecute=$false;$si.CreateNoWindow=$true + $stageProcess=[Diagnostics.Process]::Start($si) + if(!$stageProcess){throw 'DISPOSABLE_CHILD_NOT_STARTED'} + $sw=[Diagnostics.Stopwatch]::StartNew() + while(!(Test-Path $armed) -and $sw.Elapsed.TotalSeconds -lt 60){ + $stageProcess.Refresh() + if($stageProcess.HasExited){throw ('STAGING_CHILD_FAILED_EARLY_'+$stageProcess.ExitCode)} + Start-Sleep -Milliseconds 150 + } + if(!(Test-Path $armed)){throw 'DISPOSABLE_STAGING_CHILD_TIMEOUT'} + $signal=Get-Content $armed -Raw|ConvertFrom-Json + if($signal.ChildProcessId -ne $stageProcess.Id -or $signal.Phase -cne $Phase -or $signal.CommittedManagedCount -ne $(if($Phase -eq 'COMMITTING'){2}else{0})){throw 'PARENT_CHILD_SIGNAL_OR_PHASE_MISMATCH'} + $phaseFile=Join-Path $stage 'phase.txt' + if(!(Test-Path $phaseFile) -or ((Get-Content $phaseFile -Raw) -cne ('TBME_UPGRADE_R92_OWNED_STAGE_V1|'+$Phase))){throw 'DISPOSABLE_OWNED_JOURNAL_PHASE_WRONG'} + $partialReplacement=($Phase -eq 'COMMITTING') + if($partialReplacement -and (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main176){throw 'COMMITTING_PREKILL_NOT_NEW_MAIN'} + if(!$partialReplacement -and (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main175){throw 'PREPARED_SHOULD_NOT_TOUCH_MAIN'} + if((Hash (Join-Path $app 'install_state.json')) -cne $stateBefore){throw 'PARTIAL_STAGE_ALREADY_CHANGED_INSTALLED_VERSION'} + $passes+=@('STAGED_DURABLE_JOURNAL_EXACT_PHASE') + $phaseNow='FORCE_TERMINATE_ONLY_NEW_TEST_CHILD_ON_DISPOSABLE' + $stageProcess.Refresh() + if($stageProcess.HasExited){throw 'CHILD_ENDED_WITHOUT_EXTERNAL_TERMINATION'} + $stageProcess.Kill() + if(!$stageProcess.WaitForExit(15000)){throw 'OWNED_TEST_CHILD_WILL_NOT_TERMINATE'} + $terminated=$true + if(!(Test-Path $stage)){throw 'KILL_DESTROYED_EXPECTED_STAGE'} + $passes+=@('ACTUAL_STAGING_HELPER_PROCESS_ABRUPTLY_KILLED') + $phaseNow='COLD_NATIVE_SETUP_PROCESS_RECOVERS_JOURNAL_THEN_UPGRADES' + $result=Run-Exact $currentExe '/update /quiet' + if($result -ne 0){throw ('NATIVE_UPDATE_DID_NOT_RECOVER_AND_UPGRADE_'+$result)} + if(Test-Path $stage){throw 'RECOVERY_JOURNAL_NOT_CLEANED'} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main176 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $sha176 -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.6'){throw 'FINAL_INSTALLED_RELEASE_IDENTITY_MISMATCH'} + if((Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json).Version -cne '1.7.6'){throw 'FINAL_STATE_VERSION_NOT_UPDATED'} + foreach($p in $protected.Keys){if((Hash $p) -cne $protected[$p]){throw ('PROTECTED_USER_DATA_CHANGED_'+[IO.Path]::GetFileName($p))}} + if((Startup) -cne $startupBefore){throw 'USER_STARTUP_PREFERENCE_CHANGED'} + if((Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.5.md')) -or !(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.6.md'))){throw 'OLD_NOTES_SURVIVED_NEW_NOTES_MISSING'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'PRODUCT_PROCESS_LEFT_RUNNING'} + $duplicates=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($duplicates.Count -ne 1 -or (Get-ItemProperty $uninstall).InstallLocation -cne $app){throw 'DUPLICATE_INSTALL_ROOT_OR_REGISTRATION'} + $passes+=@('REAL_NATIVE_176_SETUP_AUTOMATICALLY_RECOVERED_KILLED_HELPER_JOURNAL','PRIVATE_DATA_AND_SINGLE_ROOT_PRESERVED') + $phaseNow='FINAL_ACCEPTANCE' + $accepted=[ordered]@{ + Schema=1;Revision='R282';Status='PASS_DISPOSABLE_CHILD_KILLED_THEN_OFFICIAL_INSTALLER_AUTO_RECOVERED' + SourceBranchCommit=$env:GITHUB_SHA;ExactOriginalReleaseSource=$releaseHead + Official175SetupSHA256=$sha175;Official176SetupSHA256=$sha176 + Official175MainSHA256=$main175;Official176MainSHA256=$main176 + Official175ManagedFixtureRowsExact=22;OriginalOfficial175InstallerFreshInstallPerformed=$false + HistoricalV175FilesExtractedFromPinnedPublishedInstallerResources=$true + PrivilegedSensorDriverInstallPerformedForTest=$false + HostLabel=$HostLabel;Scenario=$Phase;WindowStagedAndOwned=$true + StagingHelperExternalProcessKilled=$terminated + ActualSetupExecutableProcessKilled=$false;RealPhysicalMainsCut=$false + PhaseAtTermination=$Phase;ManagedComponentsPartiallyReplacedBeforeKill=$(if($partialReplacement){2}else{0}) + ProductionVersionedSetupReinvokedInFreshProcess=$true + NativeSetupAutomaticJournalRecoveryAndInPlaceUpgrade=$true + ExactProtectedUserDataSHA256Preserved=$true;SingleCanonicalUserRoot=$true + NoOriginalOwnerPcTouched=$true;NoNonTestProcessTerminated=$true + RecoveryJournalRemoved=$true;OriginalProcessWasIsolatedTestHelper=$true + ReceiptEvidenceGate=$passes;RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($accepted|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + $receiptWritten=$true + Write-Output ('TBME_R282_ABRUPT_PROCESS_RECOVERY=PASS HOST='+$HostLabel+' PHASE='+$Phase) +}catch{ + $reason=$_.Exception.ToString() + Write-Output ('TBME_R282_PHASE_FAILED='+$phaseNow+' ERROR='+$reason) + throw +}finally{ + if($stageProcess){ + try{ + $stageProcess.Refresh() + if(!$stageProcess.HasExited -and $env:GITHUB_ACTIONS -ceq 'true' -and $env:RUNNER_ENVIRONMENT -ceq 'github-hosted'){ + # This is ONLY the known disposable helper created by this script. + $stageProcess.Kill() + [void]$stageProcess.WaitForExit(10000) + } + }finally{$stageProcess.Dispose()} + } + if(-not $receiptWritten){ + $failed=[ordered]@{Schema=1;Revision='R282';Status='FAIL';Host=$HostLabel;Scenario=$Phase + Phase=$phaseNow;Reason=$reason;TestHelperKilled=$terminated;OriginalOwnerPcTouched=$false; + CurrentUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($receipt,($failed|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + } +} +if($LASTEXITCODE -ne 0){throw 'R282_RECEIPT_HAS_FAILURE'} diff --git a/build/Test-Appearance-R145.ps1 b/build/Test-Appearance-R145.ps1 index b4b59758..58f05afb 100644 --- a/build/Test-Appearance-R145.ps1 +++ b/build/Test-Appearance-R145.ps1 @@ -57,9 +57,19 @@ $bg=[Drawing.Color]::FromArgb(255,90,70,40) $full.SetPixel(0,0,$bg);$full.SetPixel(1,0,$bg) $ink.SetPixel(1,0,$fg);$halo.SetPixel(2,0,[Drawing.Color]::White) $compose=$pType.GetMethod('Compose',$stat) +# Preserve the original 100-case contract while the semantic warning mask +# extends the production compositor's internal signature from 7 to 8 args. +$composeCount=@($compose.GetParameters()).Count +if($composeCount -ne 7 -and $composeCount -ne 8){throw ('R145_UNEXPECTED_COMPOSITOR_SIGNATURE_'+$composeCount)} +function Invoke-Compose([int]$pct,[bool]$glassMode){ + $args=[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject, + $halo.PSObject.BaseObject,$pct,[Drawing.Color]::White,$bg.PSObject.BaseObject,$glassMode) + if($composeCount -eq 8){$args+=@($null)} + return $compose.Invoke($null,$args) +} try{ foreach($pct in (1..100)){ - $b=$compose.Invoke($null,[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject,$halo.PSObject.BaseObject,$pct,[Drawing.Color]::White,$bg.PSObject.BaseObject,$false)) + $b=Invoke-Compose $pct $false try{ $back=$b.GetPixel(0,0);$glyph=$b.GetPixel(1,0);$ring=$b.GetPixel(2,0) if($back.A -ne [Math]::Round(255*$pct/100.0) -or $glyph.ToArgb() -ne $fg.ToArgb() -or $ring.A -ne 0){ @@ -68,9 +78,9 @@ try{ if($pct -in @(1,10,29,30,31,100)){$readings+=@{Percent=$pct;BackgroundAlpha=$back.A;ForegroundArgb=$glyph.ToArgb();HaloAlpha=$ring.A}} }finally{$b.Dispose()} } - $b=$compose.Invoke($null,[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject,$halo.PSObject.BaseObject,17,[Drawing.Color]::White,$bg.PSObject.BaseObject,$true)) + $b=Invoke-Compose 17 $true try{if($b.GetPixel(0,0).A -ne 0 -or $b.GetPixel(1,0).A -ne 255){throw 'R145_EXPLICIT_GLASS_ALPHA_NOT_ZERO'}}finally{$b.Dispose()} - $b=$compose.Invoke($null,[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject,$halo.PSObject.BaseObject,0,[Drawing.Color]::White,$bg.PSObject.BaseObject,$false)) + $b=Invoke-Compose 0 $false try{if($b.GetPixel(0,0).A -le 0){throw 'R145_ZERO_NUMBER_IMPLICITLY_ENABLED_GLASS'}}finally{$b.Dispose()} }finally{$full.Dispose();$ink.Dispose();$halo.Dispose()} $result=[ordered]@{Revision='R145';Status='PASS_MODEL_REAL_CONTROL_AND_PIXEL_CONTRACT';MainSHA256=(Get-FileHash $MainPath -Algorithm SHA256).Hash;ModelOpacityCases=100;PixelOpacityCases=100;RealToggleClickCycles=8;ControlKind='CheckBox-derived pill with circular thumb';ZeroOnlyExplicitGlass=$true;OpacityOneMinimum=$true;PlainInkAndChartsKeepThemeColor=$true;Screenshots='Control-only rendering, NOT native taskbar acceptance';Readings=$readings;PersonalInstallationTouched=$false} diff --git a/build/Test-BackendArchiveHash-R324.ps1 b/build/Test-BackendArchiveHash-R324.ps1 new file mode 100644 index 00000000..82ddcb94 --- /dev/null +++ b/build/Test-BackendArchiveHash-R324.ps1 @@ -0,0 +1,147 @@ +[CmdletBinding()] +param( + [string]$SetupPath = '', + [string]$ZipPath = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if ([string]::IsNullOrWhiteSpace($SetupPath)) { $SetupPath = Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' } +if ([string]::IsNullOrWhiteSpace($ZipPath)) { $ZipPath = Join-Path $PSScriptRoot '_deps\LibreHardwareMonitor.zip' } +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +if ($PSVersionTable.PSEdition -cne 'Desktop') { throw 'R324_NEEDS_NATIVE_NETFRAMEWORK_POWERSHELL' } +if (!(Test-Path -LiteralPath $SetupPath -PathType Leaf) -or !(Test-Path -LiteralPath $ZipPath -PathType Leaf)) { + throw 'R324_COMPILED_SETUP_OR_PINNED_ARCHIVE_MISSING' +} +$lock=Get-Content (Join-Path $PSScriptRoot 'dependencies.lock.json') -Raw|ConvertFrom-Json +$want=[string]$lock.dependencies.LibreHardwareMonitor.sha256 +if ($want -cne '086D9F1B5A99E643EDC2CFAAAC16051685B551E4C5AC0B32A57C58C0E529C001' -or + (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash -cne $want) { + throw 'R324_DEPENDENCY_LOCK_DIFFERENT_FROM_COMPILED_SOURCE' +} +$setup=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$method=$setup.GetType('SetupProgram',$true).GetMethod('ExtractBackendFromArchive',[Reflection.BindingFlags]'Static, NonPublic') +if ($null -eq $method) { throw 'R324_REAL_SETUP_METHOD_MISSING' } +$embedded=$setup.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') +if ($null -eq $embedded) { throw 'R324_EMBEDDED_ZIP_MISSING' } +try { + $hash=[Security.Cryptography.SHA256]::Create() + try {$embeddedHash=([BitConverter]::ToString($hash.ComputeHash($embedded))).Replace('-','')} + finally {$hash.Dispose()} +} finally {$embedded.Dispose()} +if ($embeddedHash -cne $want) { throw 'R324_SETUP_EMBEDDED_ARCHIVE_DIFFERENT_FROM_LOCK' } + +$original=[IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $ZipPath).Path) +$sourceZip=[IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $ZipPath).Path) +try { + if ($sourceZip.Entries.Count -ne 43 -or $sourceZip.Entries[0].CompressedLength -lt 33000) { + throw 'R324_PINNED_ARCHIVE_LAYOUT_CHANGED' + } +} finally {$sourceZip.Dispose()} + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r324_archive_' + [Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$canary=Join-Path $base 'external-untouched.txt' +[IO.File]::WriteAllText($canary,'KEEP_R324_OUTSIDE') +$checks=0 + +function Check-NoMutation([string]$name,[byte[]]$mutated) { + $target=Join-Path $base $name + [IO.Directory]::CreateDirectory($target)|Out-Null + $old=Join-Path $target 'previous-backend.txt' + [IO.File]::WriteAllText($old,'PRESERVE_PREVIOUS_BACKEND') + $ms=[IO.MemoryStream]::new($mutated,$false) + $args=New-Object 'object[]' 2 + $args[0]=$ms.PSObject.BaseObject + $args[1]=[string]$target + $exception='' + try { + $method.Invoke($null,$args)|Out-Null + throw ('R324_BAD_ARCHIVE_INSTALLED_'+$name) + } catch { + $errorObject=$_.Exception + while ($null -ne $errorObject.InnerException) { + $errorObject=$errorObject.InnerException + } + $exception=$errorObject.Message + } finally {$ms.Dispose()} + if ($exception -cne 'SENSOR_BACKEND_ARCHIVE_SHA_REFUSED' -or + ![IO.File]::Exists($old) -or + [IO.File]::ReadAllText($old) -cne 'PRESERVE_PREVIOUS_BACKEND' -or + @([IO.Directory]::GetFileSystemEntries($target)).Count -ne 1 -or + [IO.File]::ReadAllText($canary) -cne 'KEEP_R324_OUTSIDE') { + throw ('R324_FAIL_CLOSED_REGRESSION_'+$name+':'+$exception) + } + $script:checks++ + Write-Output ('PASS_'+$name+'_SHA_REFUSED_BEFORE_BACKEND_CHANGE') +} + +try { + $crc=[byte[]]$original.Clone() + $firstCentral=-1 + for ($i=0;$i -lt $crc.Length-46;$i++) { + if ($crc[$i] -eq 0x50 -and $crc[$i+1] -eq 0x4b -and + $crc[$i+2] -eq 0x01 -and $crc[$i+3] -eq 0x02) { + $firstCentral=$i + break + } + } + if ($firstCentral -lt 0) { throw 'R324_CENTRAL_HEADER_MISSING' } + $crc[$firstCentral+16]=[byte]($crc[$firstCentral+16] -bxor 1) + Check-NoMutation 'invalid_central_crc' $crc + + $deflate=[byte[]]$original.Clone() + if ([BitConverter]::ToUInt32($deflate,0) -ne 0x04034b50) { + throw 'R324_FIRST_LOCAL_HEADER_MISSING' + } + $payloadStart=30+[BitConverter]::ToUInt16($deflate,26)+[BitConverter]::ToUInt16($deflate,28) + $offset=$payloadStart+32000 + if ($offset -ge $deflate.Length) { throw 'R324_DEFLATE_OFFSET_OUTSIDE_ARCHIVE' } + $deflate[$offset]=[byte]($deflate[$offset] -bxor 2) + Check-NoMutation 'altered_compressed_payload' $deflate + + $valid=Join-Path $base 'valid' + [IO.Directory]::CreateDirectory($valid)|Out-Null + [IO.File]::WriteAllText((Join-Path $valid 'old-backend.txt'),'OLD') + $resource=$setup.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + try { + $args=New-Object 'object[]' 2 + $args[0]=$resource.PSObject.BaseObject + $args[1]=[string]$valid + $method.Invoke($null,$args)|Out-Null + } finally {$resource.Dispose()} + $files=@([IO.Directory]::GetFiles($valid,'*',[IO.SearchOption]::AllDirectories)) + if ($files.Count -ne 43 -or [IO.File]::Exists((Join-Path $valid 'old-backend.txt'))) { + throw 'R324_VALID_RESOURCE_EXTRACTION_FAILED' + } + $sourceZip=[IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $ZipPath).Path) + try { + foreach($entry in $sourceZip.Entries) { + $file=Join-Path $valid ($entry.FullName.Replace('/','\')) + if (![IO.File]::Exists($file) -or (Get-Item -LiteralPath $file).Length -ne $entry.Length) { + throw ('R324_VALID_FILE_LAYOUT_MISMATCH_'+$entry.FullName) + } + $reader=$entry.Open() + try { + $sha=[Security.Cryptography.SHA256]::Create() + try {$expected=([BitConverter]::ToString($sha.ComputeHash($reader))).Replace('-','')} + finally {$sha.Dispose()} + } finally {$reader.Dispose()} + if ((Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash -cne $expected) { + throw ('R324_VALID_FILE_SHA_MISMATCH_'+$entry.FullName) + } + } + } finally {$sourceZip.Dispose()} + if ([IO.File]::ReadAllText($canary) -cne 'KEEP_R324_OUTSIDE') { + throw 'R324_POSITIVE_EXTERNAL_CANARY_CHANGED' + } + $checks++ + Write-Output 'PASS_VALID_LOCKED_PE_ARCHIVE_43_FILES_ALL_SHA' + if ($checks -ne 3) { throw ('R324_CHECK_COUNT_MISMATCH_'+$checks) } + Write-Output ('R324_BACKEND_SHA_PREFLIGHT_NATIVE_PASS='+$checks+'/3') +} finally { + if ([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r324_archive_',[StringComparison]::Ordinal)) { + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-BackendZip-R322.ps1 b/build/Test-BackendZip-R322.ps1 new file mode 100644 index 00000000..25dfe4ee --- /dev/null +++ b/build/Test-BackendZip-R322.ps1 @@ -0,0 +1,132 @@ +[CmdletBinding()] +param( + [string]$SetupPath = '', + [string]$ZipPath = '' +) +Set-StrictMode -Version Latest +if ([string]::IsNullOrWhiteSpace($SetupPath)) { $SetupPath = Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' } +if ([string]::IsNullOrWhiteSpace($ZipPath)) { $ZipPath = Join-Path $PSScriptRoot '_deps\LibreHardwareMonitor.zip' } +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +if (!(Test-Path -LiteralPath $SetupPath) -or !(Test-Path -LiteralPath $ZipPath)) { throw 'R322_REQUIRED_BUILD_MISSING' } + +$lock = Get-Content (Join-Path $PSScriptRoot 'dependencies.lock.json') -Raw | ConvertFrom-Json +$actualZipSha = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash +if ($actualZipSha -ne [string]$lock.dependencies.LibreHardwareMonitor.sha256) { throw 'R322_LOCKED_ZIP_SHA_MISMATCH' } + +$setup = [Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$program = $setup.GetType('SetupProgram', $true) +$method = $program.GetMethod('ExtractBackendFromArchive', [Reflection.BindingFlags]'Static, NonPublic') +if ($null -eq $method) { throw 'R322_EXTRACT_METHOD_MISSING' } +$embedded = $setup.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') +if ($null -eq $embedded) { throw 'R322_PE_RESOURCE_MISSING' } +$sha = [Security.Cryptography.SHA256]::Create() +try { + $embeddedSha = ([BitConverter]::ToString($sha.ComputeHash($embedded))).Replace('-', '') + if ($embeddedSha -ne $actualZipSha) { throw 'R322_PE_RESOURCE_HASH_MISMATCH' } + $embedded.Position = 0 +} finally { $sha.Dispose() } + +$base = Join-Path ([IO.Path]::GetTempPath()) ('tbme_r322_zip_' + [Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base) | Out-Null +$passed = 0 +$caseCount = 0 + +function New-BadZip([string[]]$names, [bool]$large = $false, [bool]$link = $false) { + $memory = New-Object IO.MemoryStream + $zip = [IO.Compression.ZipArchive]::new($memory, [IO.Compression.ZipArchiveMode]::Create, $true) + try { + for ($i = 0; $i -lt $names.Count; $i++) { + $entry = $zip.CreateEntry($names[$i]) + if ($link -and $i -eq 0) { $entry.ExternalAttributes = [int](-1577127936) } + $stream = $entry.Open() + try { + if ($large) { + $content = New-Object 'byte[]' (8*1024*1024+1) + $stream.Write($content,0,$content.Length) + } else { + [byte[]]$content = [Text.Encoding]::ASCII.GetBytes('test') + $stream.Write($content,0,$content.Length) + } + } finally { $stream.Dispose() } + } + } finally { $zip.Dispose() } + return ,$memory.ToArray() +} + +function Check-Refusal([string]$name, [byte[]]$zipData, [string]$marker) { + $script:caseCount++ + $target = Join-Path $base ('negative_' + $script:caseCount) + [IO.Directory]::CreateDirectory($target) | Out-Null + $sentinel = Join-Path $target 'sentinel.txt' + [IO.File]::WriteAllText($sentinel,'original') + $stream = [IO.MemoryStream]::new($zipData,$false) + $observed = '' + try { + $reflectionArgs = New-Object 'object[]' 2 + $reflectionArgs[0] = $stream.PSObject.BaseObject + $reflectionArgs[1] = [string]$target + $method.Invoke($null, $reflectionArgs) | Out-Null + throw ('R322_UNSAFE_ACCEPTED_' + $name) + } catch { + $failure = $_.Exception + while ($null -ne $failure.InnerException) { $failure = $failure.InnerException } + $observed = $failure.Message + } finally { $stream.Dispose() } + if (!$observed.Contains($marker) -or [IO.File]::ReadAllText($sentinel) -ne 'original' -or + @([IO.Directory]::GetFileSystemEntries($target)).Count -ne 1) { + throw ('R322_NEGATIVE_FAILED_' + $name + ' message=' + $observed) + } + $script:passed++ + Write-Output ('PASS ' + $name + ' => ' + $marker) +} + +try { + $positiveRoot = Join-Path $base 'positive' + [IO.Directory]::CreateDirectory($positiveRoot) | Out-Null + [IO.File]::WriteAllText((Join-Path $positiveRoot 'stale.tmp'),'previous backend') + $reflectionArgs = New-Object 'object[]' 2 + $reflectionArgs[0] = $embedded.PSObject.BaseObject + $reflectionArgs[1] = [string]$positiveRoot + try { $method.Invoke($null,$reflectionArgs) | Out-Null } + finally { $embedded.Dispose() } + if ((Test-Path -LiteralPath (Join-Path $positiveRoot 'stale.tmp'))) { throw 'R322_OLD_BACKEND_NOT_REPLACED' } + $zip = [IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $ZipPath).Path) + try { + if ($zip.Entries.Count -ne 43) { throw 'R322_REFERENCE_ENTRY_COUNT_CHANGED' } + foreach ($entry in $zip.Entries) { + $file = Join-Path $positiveRoot ($entry.FullName.Replace('/', '\')) + if (!(Test-Path -LiteralPath $file -PathType Leaf) -or + (Get-Item -LiteralPath $file).Length -ne $entry.Length) { + throw ('R322_EXTRACTED_ENTRY_MISMATCH_' + $entry.FullName) + } + $reader = $entry.Open() + try { + $digest=[Security.Cryptography.SHA256]::Create() + try { + $expected=([BitConverter]::ToString($digest.ComputeHash($reader))).Replace('-','') + $actual=(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash + if($expected -ne $actual){throw ('R322_ENTRY_DIGEST_MISMATCH_' + $entry.FullName)} + } finally {$digest.Dispose()} + } finally {$reader.Dispose()} + } + } finally { $zip.Dispose() } + $caseCount++; $passed++ + Write-Output 'PASS embedded_PE_zip_exact_43_files_and_replaced_backend' + + Check-Refusal 'traversal' (New-BadZip @('../outside.txt')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'windows_backslash' (New-BadZip @('..\outside.txt')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'absolute_path' (New-BadZip @('/outside.txt')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'ads_colon' (New-BadZip @('evil.txt:ads')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'case_alias_duplicate' (New-BadZip @('a.bin','A.BIN')) 'SENSOR_BACKEND_ARCHIVE_PATH_OR_DUPLICATE' + Check-Refusal 'symlink' (New-BadZip @('symlink.bin') $false $true) 'SENSOR_BACKEND_ARCHIVE_LINK_REFUSED' + Check-Refusal 'oversize' (New-BadZip @('big.bin') $true $false) 'SENSOR_BACKEND_ARCHIVE_SIZE_REFUSED' + Check-Refusal 'wrong_manifest' (New-BadZip @('valid.bin')) 'SENSOR_BACKEND_ARCHIVE_PIN_MISMATCH' + if (Test-Path -LiteralPath (Join-Path $base 'outside.txt')) { throw 'R322_EXTERNAL_CANARY_MODIFIED' } + if ($caseCount -ne 9 -or $passed -ne 9) { throw 'R322_TEST_COUNT_MISMATCH' } + Write-Output ('R322_BACKEND_ZIP_NATIVE_PASS=' + $passed + '/' + $caseCount) +} finally { + if ($null -ne $embedded) { $embedded.Dispose() } + if (Test-Path -LiteralPath $base) { [IO.Directory]::Delete($base,$true) } +} diff --git a/build/Test-GlassThermal-R272.ps1 b/build/Test-GlassThermal-R272.ps1 new file mode 100644 index 00000000..2520edae --- /dev/null +++ b/build/Test-GlassThermal-R272.ps1 @@ -0,0 +1,71 @@ +[CmdletBinding()] +param( + [string]$MainPath='', + [string]$OutDir='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($MainPath)){$MainPath=Join-Path $PSScriptRoot '_out\App\TaskbarMonitorEnhanced.exe'} +if([string]::IsNullOrWhiteSpace($OutDir)){$OutDir=Join-Path $PSScriptRoot '..\artifacts\thermal-r272'} +$MainPath=(Resolve-Path -LiteralPath $MainPath).Path +$OutDir=[IO.Path]::GetFullPath($OutDir) +if(Test-Path $OutDir){throw 'R272 output already exists: fail closed against stale evidence'} +[void][IO.Directory]::CreateDirectory($OutDir) +$expectedSha=(Get-FileHash -LiteralPath $MainPath -Algorithm SHA256).Hash +$sourceHead=(& git -C (Join-Path $PSScriptRoot '..') rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0){throw 'R272 git source head unavailable'} +$dir=Join-Path $OutDir 'real-thermal-pixels' +$p=[Diagnostics.Process]::new() +$p.StartInfo.FileName=$MainPath +$p.StartInfo.Arguments='--glassthermalproof "'+$dir+'"' +$p.StartInfo.UseShellExecute=$false +$p.StartInfo.CreateNoWindow=$true +$p.StartInfo.RedirectStandardOutput=$true +$p.StartInfo.RedirectStandardError=$true +try{ + if(!$p.Start()){throw 'R272 child process did not start'} + $stdoutAsync=$p.StandardOutput.ReadToEndAsync() + $stderrAsync=$p.StandardError.ReadToEndAsync() + if(!$p.WaitForExit(180000)){ + try{$p.Kill();$p.WaitForExit()}catch{} + throw 'R272 child process timeout' + } + $p.WaitForExit() + $code=$p.ExitCode + $stdout=$stdoutAsync.GetAwaiter().GetResult() + $stderr=$stderrAsync.GetAwaiter().GetResult() +}finally{$p.Dispose()} +[IO.File]::WriteAllText((Join-Path $OutDir 'child-stdout.txt'),$stdout,[Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $OutDir 'child-stderr.txt'),$stderr,[Text.UTF8Encoding]::new($false)) +if($code -ne 0 -or !$stdout.Contains('TBME_THERMAL_GLASS_PROOF=PASS') -or $stderr.Contains('=FAIL')){ + throw ('R272 native compiled pixel proof failed childCode='+$code+' stdout='+$stdout+' stderr='+$stderr) +} +$receiptPath=Join-Path $dir 'THERMAL_GLASS_PROOF.json' +if(!(Test-Path $receiptPath)){throw 'R272 typed receipt absent'} +$v=Get-Content $receiptPath -Raw | ConvertFrom-Json +if($v.Revision -cne 'R272' -or $v.Status -cne 'PASS' -or + $v.Source -cne 'REAL_OVERLAY_THEME_AND_METRIC_PAINTER' -or + $v.HotC -ne 95 -or $v.ColdC -ne 60 -or $v.ThresholdC -ne 85 -or + $v.Themes -ne 49 -or $v.ThemeBackgroundPairs -ne 98 -or + $v.HotRedPixelsMinimum -lt 40 -or $v.ColdRedPixelsMaximum -ne 0 -or + $v.ClearBackgroundPixelsMinimum -le 7440 -or + $v.OptOutTested -ne $true -or $v.NoNativeHostInput -ne $true -or + $v.NoUserSettingsOrSensorMutation -ne $true){ + throw 'R272 typed 49-theme thermal pixel acceptance contract failed' +} +$actualSha=(Get-FileHash $MainPath -Algorithm SHA256).Hash +if($actualSha -cne $expectedSha){throw 'R272 compiled Main mutated after proof'} +$receipt=[ordered]@{ + Revision='R272' + Status='PASS' + GitHead=$sourceHead + MainSHA256=$expectedSha + PixelReceiptSHA256=(Get-FileHash $receiptPath).Hash + ThemePairs=$v.ThemeBackgroundPairs + AlertPixelMinimum=$v.HotRedPixelsMinimum + ColdPixelMaximum=$v.ColdRedPixelsMaximum + ClearPixelMinimum=$v.ClearBackgroundPixelsMinimum + VisualScope='Real production bitmap painter with synthetic CPU temperature; no live HWND or notification' +} +[IO.File]::WriteAllText((Join-Path $OutDir 'R272_ACCEPTANCE.json'),($receipt|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +Write-Output ('TBME_R272_THERMAL_GLASS=PASS 49_THEMES=TRUE PAIRS='+$v.ThemeBackgroundPairs+' MIN_RED='+$v.HotRedPixelsMinimum+' BINARY_SHA='+$expectedSha) diff --git a/build/Test-Native-Presentation-R243.ps1 b/build/Test-Native-Presentation-R243.ps1 new file mode 100644 index 00000000..7ff2d271 --- /dev/null +++ b/build/Test-Native-Presentation-R243.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R243_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R243_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R243_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.3' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R243_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R243_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R243_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R243';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R243_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-Native-Presentation-R256.ps1 b/build/Test-Native-Presentation-R256.ps1 new file mode 100644 index 00000000..c299840e --- /dev/null +++ b/build/Test-Native-Presentation-R256.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R256_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R256_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R256_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.4' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R256_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R256_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R256_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R256';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R256_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-Native-Presentation-R265.ps1 b/build/Test-Native-Presentation-R265.ps1 new file mode 100644 index 00000000..f5a5f226 --- /dev/null +++ b/build/Test-Native-Presentation-R265.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R265_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R265_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R265_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.5' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R265_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R265_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R265_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R265';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R265_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-Native-Presentation-R273.ps1 b/build/Test-Native-Presentation-R273.ps1 new file mode 100644 index 00000000..e3ac4d40 --- /dev/null +++ b/build/Test-Native-Presentation-R273.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R273_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R273_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R273_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.6' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R273_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R273_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R273_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R273';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R273_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-NoPawnIO-OssRelease.ps1 b/build/Test-NoPawnIO-OssRelease.ps1 index 3c54d8c9..296ebb32 100644 --- a/build/Test-NoPawnIO-OssRelease.ps1 +++ b/build/Test-NoPawnIO-OssRelease.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $LegacyDir=Join-Path $Root 'artifacts\_r85_forbidden_pawnio_fixture' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' -$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $Main=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' $LegacyVerify=Join-Path $LegacyDir 'legacy_verify.json' $Reader=Join-Path $PSScriptRoot 'Verify-Embedded-Main.ps1' if(Test-Path -LiteralPath $LegacyDir){throw 'R85 UNSAFE RERUN: FORBIDDEN FIXTURE ALREADY EXISTS'} @@ -56,7 +56,7 @@ try { Write-Output ('R85_OSS_NO_PROPRIETARY_BUNDLE_PASS productionEmbedded=False negativeLegacyResources='+$v.Resources+' negativeRejected=True') } finally { if($created -and (Test-Path -LiteralPath $LegacyDir)){ - $allow=@('TaskbarMonitorEnhanced_Setup_1.7.2.exe','TaskbarMonitorEnhanced_Setup_1.7.2.exe.config','legacy_verify.json') + $allow=@('TaskbarMonitorEnhanced_Setup_1.7.8.exe','TaskbarMonitorEnhanced_Setup_1.7.8.exe.config','legacy_verify.json') $objects=@(Get-ChildItem -LiteralPath $LegacyDir -Force) if(@($objects | Where-Object {$_.PSIsContainer -or $_.Name -notin $allow}).Count -ne 0){ throw ('R85 DEGRADED: UNKNOWN FILE IN OWNED STAGING; PRESERVED '+$LegacyDir) diff --git a/build/Test-PawnIO-External-Degrade.ps1 b/build/Test-PawnIO-External-Degrade.ps1 index 514933f3..8ac629f3 100644 --- a/build/Test-PawnIO-External-Degrade.ps1 +++ b/build/Test-PawnIO-External-Degrade.ps1 @@ -1,29 +1,84 @@ -# R85 hermetic negative runtime test: a missing proprietary PawnIO binary cannot install -# driver/services or mutate the real host. An isolated copy only mocks the read-only -# PawnIO version discovery function so installed PawnIO on the developer PC is untouched. +# R85: hermetic early PawnIO-degrade regression on disposable GitHub Windows. +# Dedicated R291 native A/B tests exercise the real privileged path boundary. +# This test mocks only its isolated fixture boundary and PawnIO discovery. $ErrorActionPreference='Stop' Set-StrictMode -Version Latest +# Never write even a test config to a real owner's user profile. +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){throw 'R85_ONLY_DISPOSABLE_GITHUB_WINDOWS'} $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $Helper=Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1' $Base=Join-Path $Root 'artifacts\_r85_external_degrade_test' $Payload=Join-Path $Base 'payload' -$App=Join-Path $Base 'local_app' +$Profile=Join-Path $Base 'isolated-profile' +$App=Join-Path $Profile 'AppData\Local\TaskbarMonitorEnhanced' $Mock=Join-Path $Base 'helper_mocked_pawnio_discovery.ps1' $Result=Join-Path $App 'Logs\sensor_install_result.json' $Log=Join-Path $App 'Logs\sensor_install.log' -if(Test-Path -LiteralPath $Base){throw 'R85 UNSAFE FIXTURE REUSE'} +if(Test-Path -LiteralPath $Base){throw 'R85 GUEST_FIXTURE_COLLISION_NO_USER_DATA_TOUCH'} if(!(Test-Path -LiteralPath $Helper -PathType Leaf)){throw 'R85 HELPER MISSING'} $source=Get-Content -LiteralPath $Helper -Raw $guardAt=$source.IndexOf('PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED',[StringComparison]::Ordinal) -$captureAt=$source.IndexOf('if(-not(Capture-PreviousSensorLayer))',[StringComparison]::Ordinal) -if($guardAt -lt 0 -or $captureAt -le $guardAt){throw 'R85 EARLY DEGRADED GUARD NOT BEFORE SENSOR MUTATION'} +# R344 adds a separate *Uninstall-only* PREPARED snapshot before PawnIO +# discovery. The R85 regression is for Install: bind this static check to +# the Install-specific capture following the completed Uninstall branch. +$uninstallBegin=$source.IndexOf('if($Mode -eq ''Uninstall'')',[StringComparison]::Ordinal) +$uninstallEnd=$source.IndexOf('# R294: one exclusive read',$uninstallBegin,[StringComparison]::Ordinal) +$captureAt=$source.IndexOf('if(-not(Capture-PreviousSensorLayer))',$uninstallEnd,[StringComparison]::Ordinal) +if($uninstallBegin -lt 0 -or $uninstallEnd -le $uninstallBegin -or + $guardAt -lt $uninstallEnd -or $captureAt -le $guardAt){ + throw 'R85 INSTALL_DEGRADED_GUARD_NOT_BEFORE_INSTALL_SENSOR_MUTATION' +} +if($source.Substring($uninstallBegin,$uninstallEnd-$uninstallBegin).IndexOf( + 'if(-not(Capture-PreviousSensorLayer))',[StringComparison]::Ordinal) -lt 0){ + throw 'R85 R344_UNINSTALL_JOURNAL_CAPTURE_MISSING' +} $tok=$null;$errs=$null $ast=[Management.Automation.Language.Parser]::ParseInput($source,[ref]$tok,[ref]$errs) if($errs.Count -ne 0){throw 'R85 HELPER HAS PARSER ERRORS'} -$funcs=@($ast.FindAll({param($x) $x -is [Management.Automation.Language.FunctionDefinitionAst] -and $x.Name -ceq 'Get-PawnIOVersion'},$true)) -if($funcs.Count -ne 1){throw 'R85 EXACT FUNCTION REPLACEMENT AMBIGUOUS'} -$node=$funcs[0] -$mocked=$source.Substring(0,$node.Extent.StartOffset)+"function Get-PawnIOVersion { return '' }"+$source.Substring($node.Extent.EndOffset) +$funcs=@($ast.FindAll({param($x) + $x -is [Management.Automation.Language.FunctionDefinitionAst] -and + $x.Name -in @('Get-PawnIOVersion','Assert-SensorPrivilegedBoundary') +},$true)) +if($funcs.Count -ne 2 -or @($funcs|Where-Object Name -eq 'Get-PawnIOVersion').Count -ne 1 -or + @($funcs|Where-Object Name -eq 'Assert-SensorPrivilegedBoundary').Count -ne 1){ + throw 'R85_TWO_EXACT_MOCK_FUNCTIONS_REQUIRED' +} +$preflight=$source.IndexOf('$validatedSensorSid=Assert-SensorPrivilegedBoundary',[StringComparison]::Ordinal) +$logWrite=$source.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) +if($preflight -lt 0 -or $logWrite -le $preflight){throw 'R85_UNMODIFIED_PRODUCTION_GUARD_MISSING'} +$escapeApp=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($App))) +$escapeProfile=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($Profile))) +$escapePayload=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($Payload))) +$escapeUser=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([Security.Principal.WindowsIdentity]::GetCurrent().Name)) +$escapeSid=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) +$mockBoundary=@' +function Assert-SensorPrivilegedBoundary([string]$RequestedMode,[string]$RequestedPayload,[string]$RequestedAppRoot,[string]$RequestedUser){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows'){throw 'R85_MOCK_ONLY_HOSTED_DISPOSABLE'} + $app=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__APP__')) + $profile=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PROFILE__')) + $payload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PAYLOAD__')) + $user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__USER__')) + $sid=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__SID__')) + if($RequestedMode -cne 'Install' -or $RequestedUser -cne $user -or + -not [string]::Equals([IO.Path]::GetFullPath($RequestedAppRoot),$app,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($app,[IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local\TaskbarMonitorEnhanced')),[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([IO.Path]::GetFullPath($RequestedPayload),$payload,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R85_MOCK_REJECTS_OUTSIDE_OWNED_FIXTURE' + } + $script:ValidatedSensorProfileRoot=$profile + return $sid +} +'@ +$mockBoundary=$mockBoundary.Replace('__APP__',$escapeApp).Replace('__PROFILE__',$escapeProfile).Replace('__PAYLOAD__',$escapePayload).Replace('__USER__',$escapeUser).Replace('__SID__',$escapeSid) +$mocked=$source +foreach($fn in @($funcs|Sort-Object {$_.Extent.StartOffset} -Descending)){ + $replacement=if($fn.Name -ceq 'Get-PawnIOVersion'){"function Get-PawnIOVersion { return '' }"}else{$mockBoundary} + $mocked=$mocked.Substring(0,$fn.Extent.StartOffset)+$replacement+$mocked.Substring($fn.Extent.EndOffset) +} +if(-not $mocked.Contains('R85_MOCK_REJECTS_OUTSIDE_OWNED_FIXTURE')){throw 'R85_BOUNDARY_MOCK_INJECTION_FAILED'} $done=$false try { New-Item -ItemType Directory -Path $Payload -Force -ErrorAction Stop|Out-Null @@ -32,7 +87,7 @@ try { [IO.File]::WriteAllBytes((Join-Path $Payload 'TaskbarMonitorSensorSupervisor.exe'),[byte[]](1,2,3)) [IO.File]::WriteAllBytes((Join-Path $App 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'),[byte[]](1,2,3)) [IO.File]::WriteAllText($Mock,$mocked,[Text.UTF8Encoding]::new($false)) - $exec=@(& pwsh -NoProfile -File $Mock -Mode Install -PayloadDir $Payload -AppRoot $App -UserId 'R85_TEST_FIXTURE_NO_DRIVER') + $exec=@(& pwsh -NoProfile -File $Mock -Mode Install -PayloadDir $Payload -AppRoot $App -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name)) if($LASTEXITCODE -ne 0 -or !(Test-Path -LiteralPath $Result)){throw 'R85 MOCK HOST SENSOR HELPER FAILED'} $receipt=Get-Content -LiteralPath $Result -Raw|ConvertFrom-Json if($receipt.Status -cne 'DEGRADED' -or @@ -50,19 +105,19 @@ try { $files=@(Get-ChildItem -LiteralPath $Base -Recurse -File) $allowed=@( 'payload\TaskbarMonitorSensorBroker.exe','payload\TaskbarMonitorSensorSupervisor.exe', - 'local_app\SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll', - 'local_app\Logs\sensor_install_result.json','local_app\Logs\sensor_install.log', + 'isolated-profile\AppData\Local\TaskbarMonitorEnhanced\SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll', + 'isolated-profile\AppData\Local\TaskbarMonitorEnhanced\Logs\sensor_install_result.json','isolated-profile\AppData\Local\TaskbarMonitorEnhanced\Logs\sensor_install.log', 'helper_mocked_pawnio_discovery.ps1') - if($files.Count -ne $allowed.Count -or @($files|Where-Object {$_.FullName.Substring($Base.Length+1) -notin $allowed}).Count -ne 0){throw 'R85 UNKNOWN FIXTURE FILES'} + if($files.Count -ne $allowed.Count -or @($files|Where-Object {$_.FullName.Substring($Base.Length+1) -notin $allowed}).Count -ne 0){throw 'R85_UNKNOWN_FIXTURE_FILES'} Write-Output 'R85_MISSING_PAWNIO_DEGRADED_FAIL_CLOSED_NO_DRIVER_INSTALL_NO_ROLLBACK_NO_OS_CHANGE=PASS' $done=$true } finally { if($done){ - # Only this preflight-owned, exact-path fixture tree is removed. + # Delete only this initially absent, owned fixture; never touch user AppData. Remove-Item -LiteralPath $Base -Recurse -Force -ErrorAction Stop - if(Test-Path -LiteralPath $Base){throw 'R85 FIXTURE CLEANUP FAILED'} + if(Test-Path -LiteralPath $Base){throw 'R85_FIXTURE_CLEANUP_FAILED'} }else{ - Write-Warning ('R85 TEST FAILED; exact diagnostic fixture preserved at '+$Base) + Write-Warning ('R85 GUEST FAILURE: owned fixture preserved at '+$Base) } } exit 0 diff --git a/build/Test-R256-FreshSameVersion-Disposable.ps1 b/build/Test-R256-FreshSameVersion-Disposable.ps1 new file mode 100644 index 00000000..1671b268 --- /dev/null +++ b/build/Test-R256-FreshSameVersion-Disposable.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$CandidateDir, + [Parameter(Mandatory=$true)][ValidateSet("A","B")][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" + +# Test-only, exact source, genuine compiled .NET 4.x Windows setup. +# No run on owner's PC, no driver install direct from this harness. +if($env:GITHUB_ACTIONS -cne "true" -or + $env:RUNNER_ENVIRONMENT -cne "github-hosted" -or + $env:RUNNER_OS -cne "Windows" -or + $env:GITHUB_REPOSITORY -cne "GOD13emad/TaskbarMonitorEnhanced" -or + ($env:GITHUB_REF -cne "refs/heads/release/r256-v174-fresh-idempotence" -and + $env:GITHUB_REF -cne "refs/heads/main") -or + $env:COMPUTERNAME -match "(?i)EMAD|ALI|LAPTOP" -or + -not [Environment]::UserInteractive) { + throw "R256_REFUSE_NON_DISPOSABLE_GITHUB_HOST" +} +$repo="GOD13emad/TaskbarMonitorEnhanced" +$app=Join-Path $env:LOCALAPPDATA "TaskbarMonitorEnhanced" +$reg="HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced" +$build=Get-Content -LiteralPath (Join-Path $CandidateDir "CANDIDATE.json") -Raw|ConvertFrom-Json -ErrorAction Stop +$setup=[IO.Path]::GetFullPath((Join-Path $CandidateDir "Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe")) +$out=Join-Path $env:RUNNER_TEMP ("TBME_R256_"+$HostLabel) +$receipt=Join-Path $out "R256_FRESH.json" +$folder=if($HostLabel -ceq "A"){[Environment]::GetFolderPath("ProgramFiles")}else{[Environment]::GetFolderPath("ProgramFilesX86")} +$legacy=Join-Path $folder "TaskbarMonitorEnhanced" +$marker=Join-Path $legacy "TaskbarMonitorEnhanced.exe" +$owned=$false;$expectedMarker="" +$status="FAIL";$phase="PREFLIGHT";$error="" +$installedExit=$null;$sameVersionExit=$null;$negativeExit=$null;$afterCollisionExit=$null +$original=@{};$originalRun="";$removed=false +function StopNow([string]$msg){throw ("R256_"+$msg)} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return "MISSING"} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function RunCandidate([string]$arguments){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup;$psi.Arguments=$arguments + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($setup)) + $p=[Diagnostics.Process]::Start($psi) + if($null -eq $p){StopNow "CANDIDATE_SETUP_NOT_STARTED"} + try{ + if(!$p.WaitForExit(180000)){StopNow ("CANDIDATE_TIMEOUT_"+$p.Id)} + return [int]$p.ExitCode + } finally {$p.Dispose()} +} +function StartupValue{ + $key=Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -ErrorAction SilentlyContinue + if($null -eq $key){return "ABSENT"} + $v=$key.PSObject.Properties["TaskbarMonitorEnhanced"] + if($null -eq $v){return "ABSENT"} + return [string]$v.Value +} +function Snapshot{ + $paths=@("TaskbarMonitorEnhanced.exe","Uninstall.exe","config.json", + "config.json.bak","install_state.json","Source/TaskbarMonitorEnhanced.cs", + "Source/TaskbarGlass.cs","R256_PRESERVE_USER_CANARY.txt") + $m=@{} + foreach($path in $paths){$m[$path]=Hash (Join-Path $app $path)} + return $m +} +function AssertUnchanged($before,[string]$case){ + $now=Snapshot + if($now.Count -ne $before.Count){StopNow ("MAP_COUNT_"+$case)} + foreach($item in $before.Keys){ + if($before[$item] -cne $now[$item]){StopNow ("USER_OR_MANAGED_DRIFT_"+$case+"_"+$item)} + } + if((Get-ItemProperty $reg -ErrorAction Stop).DisplayVersion -cne "1.7.4"){ + StopNow ("REGISTERED_VERSION_DRIFT_"+$case) + } + if((StartupValue) -cne $originalRun){StopNow ("STARTUP_DRIFT_"+$case)} + if(Test-Path (Join-Path $app ".tbme-upgrade")){StopNow ("TRANSACTION_STAGE_LEFT_"+$case)} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow ("MAIN_PROCESS_RUNNING_"+$case)} +} +if($build.Commit -cne $env:GITHUB_SHA -or $build.Version -cne "1.7.4" -or + $build.Private -ne $true -or $build.NotForPublicInstallation -ne $true){ + StopNow "WRONG_PRIVATE_CANDIDATE_MANIFEST" +} +if((Hash $setup) -cne $build.SetupSHA256){StopNow "PRIVATE_COMPILED_SETUP_SHA_DRIFT"} +if((Test-Path $app) -or (Test-Path $reg) -or (Test-Path $legacy) -or + (Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + StopNow "GUEST_NOT_CLEAN" +} +[void](New-Item -Path $out -ItemType Directory -ErrorAction Stop) +try{ + $phase="FRESH_TEST_ONLY_SETUP" + $installedExit=RunCandidate "/install /quiet /desktop=0 /startup=0" + if($installedExit -ne 0){StopNow ("FRESH_INSTALL_EXIT_"+$installedExit)} + if((Hash (Join-Path $app "TaskbarMonitorEnhanced.exe")) -cne $build.MainSHA256 -or + (Hash (Join-Path $app "Uninstall.exe")) -cne $build.SetupSHA256){ + StopNow "FRESH_INSTALL_PRODUCT_SHA_CHANGED" + } + $source=Join-Path $app "Source/TaskbarMonitorEnhanced.cs" + $redundant=Join-Path $app "TaskbarMonitorEnhanced.cs" + if((Hash $source) -ceq "MISSING"){StopNow "SINGLE_MANAGED_SOURCE_NOT_INSTALLED"} + if(Test-Path $redundant){StopNow "REDUNDANT_ROOT_SOURCE_STILL_INSTALLED"} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow "FRESH_MAIN_ALREADY_RUNNING"} + $phase="CREATE_USER_OWNED_TEST_FIXTURE" + $config=Join-Path $app "config.json" + $backup=Join-Path $app "config.json.bak" + $canary=Join-Path $app "R256_PRESERVE_USER_CANARY.txt" + if(!(Test-Path $config)){ + [IO.File]::WriteAllText($config,'{"GlassMode":true,"Opacity":0,"OpacityWhenGlassOff":0.1,"R256UserFixture":"retain all bytes"}',[Text.UTF8Encoding]::new($false)) + } + if(!(Test-Path $backup)){ + [IO.File]::WriteAllText($backup,'{"R256Backup":"independent prior user config must remain SHA-exact"}',[Text.UTF8Encoding]::new($false)) + } + [IO.File]::WriteAllText($canary,"R256 synthetic guest user canary, no personal content",[Text.UTF8Encoding]::new($false)) + $originalRun=StartupValue + $original=Snapshot + if(@($original.Values|Where-Object {$_ -ceq "MISSING"}).Count){StopNow "EXPECTED_FRESH_FIXTURE_MISSING"} + + $phase="FIRST_SAME_VERSION_MAINTENANCE_IDEMPOTENCE" + $sameVersionExit=RunCandidate "/update /quiet" + if($sameVersionExit -ne 0){StopNow ("SAME_VERSION_MAINTENANCE_FAILED_"+$sameVersionExit)} + AssertUnchanged $original "FIRST_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "MAINTENANCE_RECREATED_REDUNDANT_SOURCE"} + + $phase="REJECT_DISPOSABLE_PROGRAMFILES_DUPLICATE" + if(Test-Path $legacy){StopNow "LEGACY_PATH_PREEXISTS"} + [void](New-Item $legacy -ItemType Directory -ErrorAction Stop) + $owned=$true + [IO.File]::WriteAllText($marker,("R256 inert duplicate executable marker "+$HostLabel),[Text.UTF8Encoding]::new($false)) + $expectedMarker=Hash $marker + if($expectedMarker -ceq "MISSING"){StopNow "LEGACY_INERT_MARKER_MISSING"} + $negativeExit=RunCandidate "/update /quiet" + if($negativeExit -ne 34){StopNow ("PROGRAMFILES_DUPLICATE_NOT_EXACTLY_REFUSED_"+$negativeExit)} + if((Hash $marker) -cne $expectedMarker){StopNow "INERT_MARKER_MODIFIED"} + AssertUnchanged $original "PROGRAMFILES_CONFLICT_REFUSAL" + if((Hash $marker) -cne $expectedMarker){StopNow "MARKER_CHANGED_BEFORE_REMOVAL"} + Remove-Item -LiteralPath $marker -Force -ErrorAction Stop + if(@(Get-ChildItem -LiteralPath $legacy -Force).Count){StopNow "LEGACY_DIR_CONTENT_NOT_OWNED"} + Remove-Item -LiteralPath $legacy -Force -ErrorAction Stop + $owned=$false + if(Test-Path $legacy){StopNow "LEGACY_DIR_NOT_REMOVED"} + $removed=$true + + $phase="SECOND_IDEMPOTENT_UPDATE_AFTER_CONFLICT_REMOVAL" + $afterCollisionExit=RunCandidate "/update /quiet" + if($afterCollisionExit -ne 0){StopNow ("AFTER_COLLISION_MAINTENANCE_FAILED_"+$afterCollisionExit)} + AssertUnchanged $original "SECOND_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "ROOT_REDUNDANCY_RETURNED"} + $status="PASS_REAL_GUEST_FRESH_R256_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT" +}catch{ + $error=$_.Exception.GetType().Name+": "+$_.Exception.Message +}finally{ + if($owned -and (Test-Path $marker) -and (Hash $marker) -ceq $expectedMarker){ + Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue + } + if($owned -and (Test-Path $legacy) -and @((Get-ChildItem -LiteralPath $legacy -Force -ErrorAction SilentlyContinue)).Count -eq 0){ + Remove-Item -LiteralPath $legacy -Force -ErrorAction SilentlyContinue + } + $e=[ordered]@{ + Revision="R256";Status=$status;Phase=$phase;Failure=$error;Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;TestOnlyCandidate=$true;PublicSetupChanged=$false + PersonalComputerTouched=$false;ReleasedV173Untouched=$true + CandidateMainSHA256=$build.MainSHA256;CandidateSetupSHA256=$build.SetupSHA256 + FreshInstallExit=$installedExit + SingleManagedSourceWithoutRootDuplicate=(!(Test-Path (Join-Path $app "TaskbarMonitorEnhanced.cs"))) + FirstSameVersionMaintenanceExit=$sameVersionExit + NegativeProgramFilesCollisionExit=$negativeExit + SecondSameVersionMaintenanceExit=$afterCollisionExit + GuestOwnedLegacyFixtureRemoved=(!(Test-Path $legacy)) + ProtectedCanaryAndConfigPreserved=($status -like "PASS_*") + RealHKLMRegistryOrCrossUserAcceptance=$false + NoPhysicalPowerCutOrExternalSecurityClaim=$true + UTC=[DateTime]::UtcNow.ToString("o") + } + [IO.File]::WriteAllText($receipt,($e|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($e|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike "PASS_*"){throw ("R256_REAL_GUEST_NOT_ACCEPTED "+$phase+" "+$error)} diff --git a/build/Test-R265-CrossUserGuard.ps1 b/build/Test-R265-CrossUserGuard.ps1 new file mode 100644 index 00000000..a2fd860c --- /dev/null +++ b/build/Test-R265-CrossUserGuard.ps1 @@ -0,0 +1,195 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + ($env:GITHUB_REF -cne 'refs/heads/release/r265-v175-sensor-crossuser-safety' -and $env:GITHUB_REF -cne 'refs/heads/main') -or + $env:COMPUTERNAME -match '(?i)EMAD|ALI|LAPTOP'){ + throw 'R265_DISPOSABLE_GUEST_ONLY' +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($identity) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R265_EXISTING_GITHUB_VM_ADMIN_REQUIRED' +} +$helper=Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1' +$expected='84A7DC21012A63C5959447D7234B365CF926028016A81B742F891951B191E7FB' +$task='TaskbarMonitorEnhanced Sensor Broker' +$user='TBME265'+$HostLabel +$description='R265 no-trigger owned task test' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R265_'+$HostLabel) +$receipt=Join-Path $out 'R265_CROSSUSER.json' +$copy=Join-Path $out 'R265_OnlyOriginalPreMutationGuard.ps1' +$payload=Join-Path $out 'EmptyPayload' +$fixtureCreated=$false;$taskOwned=$false;$accountRemoved=$false;$taskRemoved=$false +$otherSID='';$ownerSID=[string]$identity.User.Value +$otherPrincipal='';$currentPrincipal='' +$blocked=@();$allowed=@();$status='FAIL';$failure='';$phase='PREFLIGHT' +function Fail([string]$reason){throw ('R265_'+$reason)} +function VerifyTask{ + $x=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + $xml=[xml](Export-ScheduledTask -TaskName $task) + if([string]$xml.Task.RegistrationInfo.Description -cne $description -or + @($x.Triggers|Where-Object {$null -ne $_}).Count){Fail 'TEST_TASK_UNEXPECTED_OWNERSHIP_OR_TRIGGER'} + return $x +} +function InvokePrefix([string]$mode,[string]$root,[bool]$fullHelper=$false){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName='powershell.exe' + $selected=if($fullHelper){$helper}else{$copy} + $psi.Arguments='-NoProfile -NonInteractive -File "'+$selected+'" -Mode '+$mode+' -PayloadDir "'+$payload+'" -AppRoot "'+$root+'" -UserId "'+$identity.Name+'"' + $psi.WorkingDirectory=$out;$psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){Fail 'PREFIX_NOT_STARTED'} + try{ + if(!$proc.WaitForExit(25000)){Fail ('GUARD_PREFIX_TIMEOUT_'+$mode)} + return [ordered]@{Exit=[int]$proc.ExitCode;Out=$proc.StandardOutput.ReadToEnd().Trim();Err=$proc.StandardError.ReadToEnd().Trim()} + }finally{$proc.Dispose()} +} +if((Test-Path $out) -or (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) -or + (Get-LocalUser -Name $user -ErrorAction SilentlyContinue) -or + (Test-Path (Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker'))){ + Fail 'HOST_NOT_FRESH_NO_SHARED_SENSOR_TASK' +} +if((Get-FileHash $helper).Hash -cne $expected){Fail 'VERSIONED_TEST_HELPER_SHA_MISMATCH'} +New-Item -ItemType Directory -Path $out|Out-Null +try{ + $phase='COPY_ONLY_EXACT_BEFORE_MUTATION_HELPER_PREFIX' + $full=[IO.File]::ReadAllText($helper,[Text.Encoding]::UTF8) + $marker='# R264-SENSOR-GUARD-BOUNDARY-END' + if($full.Split(@($marker),[StringSplitOptions]::None).Count -ne 2){Fail 'GUARD_BOUNDARY_NOT_UNIQUE'} + $end=$full.IndexOf($marker)+$marker.Length + $uninstall=$full.IndexOf(('if('+'$Mode'+" -eq 'Uninstall'){")) + if($end -lt 3000 -or $end -ge $uninstall){Fail 'GUARD_IS_NOT_PRE_UNINSTALL'} + $only=$full.Substring(0,$end)+[Environment]::NewLine+"Write-Output 'R265_GUARD_PASSED_SAME_USER'"+[Environment]::NewLine + [IO.File]::WriteAllText($copy,$only,[Text.UTF8Encoding]::new($false)) + $tk=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($copy,[ref]$tk,[ref]$errors) + if(@($errors).Count){Fail 'GUARD_ONLY_PREFIX_PARSE_FAILED'} + New-Item -ItemType Directory -Path $payload|Out-Null + $phase='CREATE_ONLY_NEW_DISPOSABLE_LOCAL_USER' + $bytes=[byte[]]::new(32) + $rng=[Security.Cryptography.RandomNumberGenerator]::Create() + try{$rng.GetBytes($bytes)}finally{$rng.Dispose()} + $secure=ConvertTo-SecureString ([Convert]::ToBase64String($bytes)+'R9!') -AsPlainText -Force + $u=New-LocalUser -Name $user -Password $secure -AccountNeverExpires -PasswordNeverExpires -Description 'R265 test-only temporary local user' -ErrorAction Stop + $otherSID=[string]$u.SID.Value + $fixtureCreated=$true + if($otherSID -notmatch '^S-1-5-21-' -or $otherSID -ceq $ownerSID){Fail 'SID_NOT_OTHER_USER'} + $phase='REGISTER_OTHER_USER_FIXED_LEGACY_TASK_WITHOUT_TRIGGER' + $otherName=[Security.Principal.SecurityIdentifier]::new($otherSID).Translate([Security.Principal.NTAccount]).Value + $action=New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\whoami.exe') + $settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::FromMinutes(1)) + $other=New-ScheduledTaskPrincipal -UserId $otherName -LogonType Interactive -RunLevel Limited + Register-ScheduledTask -TaskName $task -Principal $other -Action $action -Settings $settings -Description $description -ErrorAction Stop|Out-Null + $taskOwned=$true + $otherPrincipal=[string](VerifyTask).Principal.UserId + if(!$otherPrincipal.EndsWith($user,[StringComparison]::OrdinalIgnoreCase)){Fail 'OTHER_TASK_NOT_OTHER_USER'} + $phase='INSTALL_AND_UNINSTALL_MUST_REFUSE_OTHER_USER_TASK_WITHOUT_MODIFICATION' + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('OtherRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app + $result=Join-Path $app 'Logs\sensor_install_result.json' + if($run.Exit -ne 0 -or !(Test-Path $result)){Fail ('OTHER_USER_NOT_REFUSED_'+$mode)} + $j=Get-Content $result -Raw|ConvertFrom-Json + if($j.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or + $j.Architecture -cne 'SHARED_PROTECTED_SENSOR_LAYER_LEFT_UNTOUCHED' -or + $run.Out -match 'R265_GUARD_PASSED_SAME_USER'){Fail ('REFUSAL_STATUS_WRONG_'+$mode)} + if([string](VerifyTask).Principal.UserId -cne $otherPrincipal){Fail ('OTHER_TASK_CHANGED_'+$mode)} + $blocked+=$mode + } + $phase='RUN_REAL_FULL_ELEVATED_HELPER_AGAINST_OTHER_ACCOUNT_OWNED_TASK' + # Unlike prefix tests above, run all released helper source. It MUST exit + # before real shared broker drivers/files or any global process mutation. + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('WholeHelperOtherRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app $true + $result=Join-Path $app 'Logs\sensor_install_result.json' + if($run.Exit -ne 0 -or !(Test-Path $result)){Fail ('REAL_FULL_HELPER_OTHER_USER_NOT_REFUSED_'+$mode+'_Exit_'+$run.Exit+'_Result_'+(Test-Path $result)+'_Out_'+$run.Out+'_Err_'+$run.Err)} + $j=Get-Content $result -Raw|ConvertFrom-Json + if($j.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or + $j.Architecture -cne 'SHARED_PROTECTED_SENSOR_LAYER_LEFT_UNTOUCHED'){ + Fail ('REAL_FULL_HELPER_ENTERED_UNSAFE_CODE_'+$mode) + } + if([string](VerifyTask).Principal.UserId -cne $otherPrincipal){Fail ('OTHER_GLOBAL_TASK_MODIFIED_BY_FULL_HELPER_'+$mode)} + $blocked+=('WholeHelper_'+$mode) + } + $phase='SAME_USER_TASK_SHOULD_PASS_GUARD_ONLY_NOT_HELPER_MUTATIONS' + $own=New-ScheduledTaskPrincipal -UserId $identity.Name -LogonType Interactive -RunLevel Limited + Register-ScheduledTask -TaskName $task -Principal $own -Action $action -Settings $settings -Description $description -Force -ErrorAction Stop|Out-Null + $currentPrincipal=[string](VerifyTask).Principal.UserId + if(!$currentPrincipal.EndsWith($env:USERNAME,[StringComparison]::OrdinalIgnoreCase)){Fail 'CURRENT_USER_TASK_NOT_OWNER'} + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('OwnRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app + if($run.Exit -ne 0 -or $run.Out -notmatch 'R265_GUARD_PASSED_SAME_USER'){ + Fail ('OWN_ACCOUNT_FALSE_REFUSAL_'+$mode+'_'+$run.Err) + } + if(!(VerifyTask)){Fail 'CURRENT_ACCOUNT_TASK_CHANGED'} + $allowed+=$mode + } + $phase='CLEANUP_ONLY_OWNED_GUEST_TASK_AND_USER' + if(!(VerifyTask)){Fail 'TASK_NOT_OWNED_AT_CLEANUP'} + Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction Stop + $taskOwned=$false + $taskRemoved=($null -eq (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue)) + if(!$taskRemoved){Fail 'OWNED_TASK_NOT_REMOVED_BEFORE_FRESH_GUARD'} + $phase='FRESH_SINGLE_USER_NO_GLOBAL_TASK_AND_NO_SHARED_BROKER_MUST_NOT_FALSE_REFUSE' + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('FreshNoTaskRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app + if($run.Exit -ne 0 -or $run.Out -notmatch 'R265_GUARD_PASSED_SAME_USER'){ + Fail ('EMPTY_GLOBAL_TASK_GUARD_FALSE_REFUSED_'+$mode+'_'+$run.Err) + } + $allowed+=('FreshNoTask_'+$mode) + } + $found=Get-LocalUser -Name $user -ErrorAction Stop + if([string]$found.SID.Value -cne $otherSID){Fail 'LOCAL_USER_IDENTITY_CHANGED'} + Disable-LocalUser -Name $user -ErrorAction Stop + Remove-LocalUser -Name $user -ErrorAction Stop + $fixtureCreated=$false + $accountRemoved=($null -eq (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) + if(!$taskRemoved -or !$accountRemoved){Fail 'FIXTURES_NOT_REMOVED'} + $status='PASS_OTHER_USER_SENSOR_TASK_PRESERVED_AND_SAME_OWNER_GUARD_ALLOWED' +}catch{$failure=$_.Exception.GetType().Name+': '+$_.Exception.Message} +finally{ + try{ + if($taskOwned -and (VerifyTask)){ + Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction Stop + $taskRemoved=($null -eq (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue)) + } + }catch{$status='FAIL_TASK_CLEANUP_UNCERTAIN';$failure+=' | '+$_.Exception.Message} + try{ + if($fixtureCreated){ + $x=Get-LocalUser -Name $user -ErrorAction SilentlyContinue + if($x -and [string]$x.SID.Value -ceq $otherSID){ + Disable-LocalUser -Name $user -ErrorAction Stop + Remove-LocalUser -Name $user -ErrorAction Stop + $accountRemoved=($null -eq (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) + } + } + }catch{$status='FAIL_ACCOUNT_CLEANUP_UNCERTAIN';$failure+=' | '+$_.Exception.Message} + $r=[ordered]@{ + Revision='R265';Status=$status;Guest=$HostLabel;Phase=$phase;Failure=$failure + SourceGuardSHA256=$expected;ExactProductionHelperAfterGuardWasNotExecuted=$true + OtherUserSid=$otherSID;CurrentUserSid=$ownerSID + OtherTaskPrincipal=$otherPrincipal;CurrentTaskPrincipal=$currentPrincipal + OtherUserModesRefused=$blocked;SameUserModesPermitted=$allowed + TestOwnedTaskDeleted=$taskRemoved;GuestUserAccountDeleted=$accountRemoved + VerifiedRealFullHelperEarlyRefusal=($blocked.Count -eq 4) + NoSensorTaskTriggered=$true;NoPawnIODriverOrSetupExecuted=$true + OwnerPCAndPublicV174Unchanged=$true;NeedsNewVersionForAnyDeployment=$true + Utc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike 'PASS_*' -or !$taskRemoved -or !$accountRemoved){ + throw ('R265_GUEST_SENSOR_OWNER_GUARD_NOT_PASS_'+$phase+'_'+$failure) +} diff --git a/build/Test-R265-FreshSameVersion-Disposable.ps1 b/build/Test-R265-FreshSameVersion-Disposable.ps1 new file mode 100644 index 00000000..e59d2a0b --- /dev/null +++ b/build/Test-R265-FreshSameVersion-Disposable.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$CandidateDir, + [Parameter(Mandatory=$true)][ValidateSet("A","B")][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" + +# Test-only, exact source, genuine compiled .NET 4.x Windows setup. +# No run on owner's PC, no driver install direct from this harness. +if($env:GITHUB_ACTIONS -cne "true" -or + $env:RUNNER_ENVIRONMENT -cne "github-hosted" -or + $env:RUNNER_OS -cne "Windows" -or + $env:GITHUB_REPOSITORY -cne "GOD13emad/TaskbarMonitorEnhanced" -or + ($env:GITHUB_REF -cne "refs/heads/release/r265-v175-sensor-crossuser-safety" -and + $env:GITHUB_REF -cne "refs/heads/main") -or + $env:COMPUTERNAME -match "(?i)EMAD|ALI|LAPTOP" -or + -not [Environment]::UserInteractive) { + throw "R265_REFUSE_NON_DISPOSABLE_GITHUB_HOST" +} +$repo="GOD13emad/TaskbarMonitorEnhanced" +$app=Join-Path $env:LOCALAPPDATA "TaskbarMonitorEnhanced" +$reg="HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced" +$build=Get-Content -LiteralPath (Join-Path $CandidateDir "CANDIDATE.json") -Raw|ConvertFrom-Json -ErrorAction Stop +$setup=[IO.Path]::GetFullPath((Join-Path $CandidateDir "Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe")) +$out=Join-Path $env:RUNNER_TEMP ("TBME_R265_"+$HostLabel) +$receipt=Join-Path $out "R265_FRESH.json" +$folder=if($HostLabel -ceq "A"){[Environment]::GetFolderPath("ProgramFiles")}else{[Environment]::GetFolderPath("ProgramFilesX86")} +$legacy=Join-Path $folder "TaskbarMonitorEnhanced" +$marker=Join-Path $legacy "TaskbarMonitorEnhanced.exe" +$owned=$false;$expectedMarker="" +$status="FAIL";$phase="PREFLIGHT";$error="" +$installedExit=$null;$sameVersionExit=$null;$negativeExit=$null;$afterCollisionExit=$null +$original=@{};$originalRun="";$removed=false +function StopNow([string]$msg){throw ("R265_"+$msg)} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return "MISSING"} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function RunCandidate([string]$arguments){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup;$psi.Arguments=$arguments + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($setup)) + $p=[Diagnostics.Process]::Start($psi) + if($null -eq $p){StopNow "CANDIDATE_SETUP_NOT_STARTED"} + try{ + if(!$p.WaitForExit(180000)){StopNow ("CANDIDATE_TIMEOUT_"+$p.Id)} + return [int]$p.ExitCode + } finally {$p.Dispose()} +} +function StartupValue{ + $key=Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -ErrorAction SilentlyContinue + if($null -eq $key){return "ABSENT"} + $v=$key.PSObject.Properties["TaskbarMonitorEnhanced"] + if($null -eq $v){return "ABSENT"} + return [string]$v.Value +} +function Snapshot{ + $paths=@("TaskbarMonitorEnhanced.exe","Uninstall.exe","config.json", + "config.json.bak","install_state.json","Source/TaskbarMonitorEnhanced.cs", + "Source/TaskbarGlass.cs","R265_PRESERVE_USER_CANARY.txt") + $m=@{} + foreach($path in $paths){$m[$path]=Hash (Join-Path $app $path)} + return $m +} +function AssertUnchanged($before,[string]$case){ + $now=Snapshot + if($now.Count -ne $before.Count){StopNow ("MAP_COUNT_"+$case)} + foreach($item in $before.Keys){ + if($before[$item] -cne $now[$item]){StopNow ("USER_OR_MANAGED_DRIFT_"+$case+"_"+$item)} + } + if((Get-ItemProperty $reg -ErrorAction Stop).DisplayVersion -cne "1.7.5"){ + StopNow ("REGISTERED_VERSION_DRIFT_"+$case) + } + if((StartupValue) -cne $originalRun){StopNow ("STARTUP_DRIFT_"+$case)} + if(Test-Path (Join-Path $app ".tbme-upgrade")){StopNow ("TRANSACTION_STAGE_LEFT_"+$case)} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow ("MAIN_PROCESS_RUNNING_"+$case)} +} +if($build.Commit -cne $env:GITHUB_SHA -or $build.Version -cne "1.7.5" -or + $build.Private -ne $true -or $build.NotForPublicInstallation -ne $true){ + StopNow "WRONG_PRIVATE_CANDIDATE_MANIFEST" +} +if((Hash $setup) -cne $build.SetupSHA256){StopNow "PRIVATE_COMPILED_SETUP_SHA_DRIFT"} +if((Test-Path $app) -or (Test-Path $reg) -or (Test-Path $legacy) -or + (Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + StopNow "GUEST_NOT_CLEAN" +} +[void](New-Item -Path $out -ItemType Directory -ErrorAction Stop) +try{ + $phase="FRESH_TEST_ONLY_SETUP" + $installedExit=RunCandidate "/install /quiet /desktop=0 /startup=0" + if($installedExit -ne 0){StopNow ("FRESH_INSTALL_EXIT_"+$installedExit)} + if((Hash (Join-Path $app "TaskbarMonitorEnhanced.exe")) -cne $build.MainSHA256 -or + (Hash (Join-Path $app "Uninstall.exe")) -cne $build.SetupSHA256){ + StopNow "FRESH_INSTALL_PRODUCT_SHA_CHANGED" + } + $source=Join-Path $app "Source/TaskbarMonitorEnhanced.cs" + $redundant=Join-Path $app "TaskbarMonitorEnhanced.cs" + if((Hash $source) -ceq "MISSING"){StopNow "SINGLE_MANAGED_SOURCE_NOT_INSTALLED"} + if(Test-Path $redundant){StopNow "REDUNDANT_ROOT_SOURCE_STILL_INSTALLED"} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow "FRESH_MAIN_ALREADY_RUNNING"} + $phase="CREATE_USER_OWNED_TEST_FIXTURE" + $config=Join-Path $app "config.json" + $backup=Join-Path $app "config.json.bak" + $canary=Join-Path $app "R265_PRESERVE_USER_CANARY.txt" + if(!(Test-Path $config)){ + [IO.File]::WriteAllText($config,'{"GlassMode":true,"Opacity":0,"OpacityWhenGlassOff":0.1,"R265UserFixture":"retain all bytes"}',[Text.UTF8Encoding]::new($false)) + } + if(!(Test-Path $backup)){ + [IO.File]::WriteAllText($backup,'{"R265Backup":"independent prior user config must remain SHA-exact"}',[Text.UTF8Encoding]::new($false)) + } + [IO.File]::WriteAllText($canary,"R265 synthetic guest user canary, no personal content",[Text.UTF8Encoding]::new($false)) + $originalRun=StartupValue + $original=Snapshot + if(@($original.Values|Where-Object {$_ -ceq "MISSING"}).Count){StopNow "EXPECTED_FRESH_FIXTURE_MISSING"} + + $phase="FIRST_SAME_VERSION_MAINTENANCE_IDEMPOTENCE" + $sameVersionExit=RunCandidate "/update /quiet" + if($sameVersionExit -ne 0){StopNow ("SAME_VERSION_MAINTENANCE_FAILED_"+$sameVersionExit)} + AssertUnchanged $original "FIRST_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "MAINTENANCE_RECREATED_REDUNDANT_SOURCE"} + + $phase="REJECT_DISPOSABLE_PROGRAMFILES_DUPLICATE" + if(Test-Path $legacy){StopNow "LEGACY_PATH_PREEXISTS"} + [void](New-Item $legacy -ItemType Directory -ErrorAction Stop) + $owned=$true + [IO.File]::WriteAllText($marker,("R265 inert duplicate executable marker "+$HostLabel),[Text.UTF8Encoding]::new($false)) + $expectedMarker=Hash $marker + if($expectedMarker -ceq "MISSING"){StopNow "LEGACY_INERT_MARKER_MISSING"} + $negativeExit=RunCandidate "/update /quiet" + if($negativeExit -ne 34){StopNow ("PROGRAMFILES_DUPLICATE_NOT_EXACTLY_REFUSED_"+$negativeExit)} + if((Hash $marker) -cne $expectedMarker){StopNow "INERT_MARKER_MODIFIED"} + AssertUnchanged $original "PROGRAMFILES_CONFLICT_REFUSAL" + if((Hash $marker) -cne $expectedMarker){StopNow "MARKER_CHANGED_BEFORE_REMOVAL"} + Remove-Item -LiteralPath $marker -Force -ErrorAction Stop + if(@(Get-ChildItem -LiteralPath $legacy -Force).Count){StopNow "LEGACY_DIR_CONTENT_NOT_OWNED"} + Remove-Item -LiteralPath $legacy -Force -ErrorAction Stop + $owned=$false + if(Test-Path $legacy){StopNow "LEGACY_DIR_NOT_REMOVED"} + $removed=$true + + $phase="SECOND_IDEMPOTENT_UPDATE_AFTER_CONFLICT_REMOVAL" + $afterCollisionExit=RunCandidate "/update /quiet" + if($afterCollisionExit -ne 0){StopNow ("AFTER_COLLISION_MAINTENANCE_FAILED_"+$afterCollisionExit)} + AssertUnchanged $original "SECOND_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "ROOT_REDUNDANCY_RETURNED"} + $status="PASS_REAL_GUEST_FRESH_R265_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT" +}catch{ + $error=$_.Exception.GetType().Name+": "+$_.Exception.Message +}finally{ + if($owned -and (Test-Path $marker) -and (Hash $marker) -ceq $expectedMarker){ + Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue + } + if($owned -and (Test-Path $legacy) -and @((Get-ChildItem -LiteralPath $legacy -Force -ErrorAction SilentlyContinue)).Count -eq 0){ + Remove-Item -LiteralPath $legacy -Force -ErrorAction SilentlyContinue + } + $e=[ordered]@{ + Revision="R265";Status=$status;Phase=$phase;Failure=$error;Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;TestOnlyCandidate=$true;PublicSetupChanged=$false + PersonalComputerTouched=$false;ReleasedV173Untouched=$true + CandidateMainSHA256=$build.MainSHA256;CandidateSetupSHA256=$build.SetupSHA256 + FreshInstallExit=$installedExit + SingleManagedSourceWithoutRootDuplicate=(!(Test-Path (Join-Path $app "TaskbarMonitorEnhanced.cs"))) + FirstSameVersionMaintenanceExit=$sameVersionExit + NegativeProgramFilesCollisionExit=$negativeExit + SecondSameVersionMaintenanceExit=$afterCollisionExit + GuestOwnedLegacyFixtureRemoved=(!(Test-Path $legacy)) + ProtectedCanaryAndConfigPreserved=($status -like "PASS_*") + RealHKLMRegistryOrCrossUserAcceptance=$false + NoPhysicalPowerCutOrExternalSecurityClaim=$true + UTC=[DateTime]::UtcNow.ToString("o") + } + [IO.File]::WriteAllText($receipt,($e|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($e|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike "PASS_*"){throw ("R265_REAL_GUEST_NOT_ACCEPTED "+$phase+" "+$error)} diff --git a/build/Test-R273-FreshSameVersion-Disposable.ps1 b/build/Test-R273-FreshSameVersion-Disposable.ps1 new file mode 100644 index 00000000..2efc7737 --- /dev/null +++ b/build/Test-R273-FreshSameVersion-Disposable.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$CandidateDir, + [Parameter(Mandatory=$true)][ValidateSet("A","B")][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" + +# Test-only, exact source, genuine compiled .NET 4.x Windows setup. +# No run on owner's PC, no driver install direct from this harness. +if($env:GITHUB_ACTIONS -cne "true" -or + $env:RUNNER_ENVIRONMENT -cne "github-hosted" -or + $env:RUNNER_OS -cne "Windows" -or + $env:GITHUB_REPOSITORY -cne "GOD13emad/TaskbarMonitorEnhanced" -or + ($env:GITHUB_REF -cne "refs/heads/fix/v1.7.6-glass-thermal-alert-r272" -and + $env:GITHUB_REF -cne "refs/heads/main") -or + $env:COMPUTERNAME -match "(?i)EMAD|ALI|LAPTOP" -or + -not [Environment]::UserInteractive) { + throw "R273_REFUSE_NON_DISPOSABLE_GITHUB_HOST" +} +$repo="GOD13emad/TaskbarMonitorEnhanced" +$app=Join-Path $env:LOCALAPPDATA "TaskbarMonitorEnhanced" +$reg="HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced" +$build=Get-Content -LiteralPath (Join-Path $CandidateDir "CANDIDATE.json") -Raw|ConvertFrom-Json -ErrorAction Stop +$setup=[IO.Path]::GetFullPath((Join-Path $CandidateDir "Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe")) +$out=Join-Path $env:RUNNER_TEMP ("TBME_R273_"+$HostLabel) +$receipt=Join-Path $out "R273_FRESH.json" +$folder=if($HostLabel -ceq "A"){[Environment]::GetFolderPath("ProgramFiles")}else{[Environment]::GetFolderPath("ProgramFilesX86")} +$legacy=Join-Path $folder "TaskbarMonitorEnhanced" +$marker=Join-Path $legacy "TaskbarMonitorEnhanced.exe" +$owned=$false;$expectedMarker="" +$status="FAIL";$phase="PREFLIGHT";$error="" +$installedExit=$null;$sameVersionExit=$null;$negativeExit=$null;$afterCollisionExit=$null +$original=@{};$originalRun="";$removed=false +function StopNow([string]$msg){throw ("R273_"+$msg)} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return "MISSING"} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function RunCandidate([string]$arguments){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup;$psi.Arguments=$arguments + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($setup)) + $p=[Diagnostics.Process]::Start($psi) + if($null -eq $p){StopNow "CANDIDATE_SETUP_NOT_STARTED"} + try{ + if(!$p.WaitForExit(180000)){StopNow ("CANDIDATE_TIMEOUT_"+$p.Id)} + return [int]$p.ExitCode + } finally {$p.Dispose()} +} +function StartupValue{ + $key=Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -ErrorAction SilentlyContinue + if($null -eq $key){return "ABSENT"} + $v=$key.PSObject.Properties["TaskbarMonitorEnhanced"] + if($null -eq $v){return "ABSENT"} + return [string]$v.Value +} +function Snapshot{ + $paths=@("TaskbarMonitorEnhanced.exe","Uninstall.exe","config.json", + "config.json.bak","install_state.json","Source/TaskbarMonitorEnhanced.cs", + "Source/TaskbarGlass.cs","R273_PRESERVE_USER_CANARY.txt") + $m=@{} + foreach($path in $paths){$m[$path]=Hash (Join-Path $app $path)} + return $m +} +function AssertUnchanged($before,[string]$case){ + $now=Snapshot + if($now.Count -ne $before.Count){StopNow ("MAP_COUNT_"+$case)} + foreach($item in $before.Keys){ + if($before[$item] -cne $now[$item]){StopNow ("USER_OR_MANAGED_DRIFT_"+$case+"_"+$item)} + } + if((Get-ItemProperty $reg -ErrorAction Stop).DisplayVersion -cne "1.7.6"){ + StopNow ("REGISTERED_VERSION_DRIFT_"+$case) + } + if((StartupValue) -cne $originalRun){StopNow ("STARTUP_DRIFT_"+$case)} + if(Test-Path (Join-Path $app ".tbme-upgrade")){StopNow ("TRANSACTION_STAGE_LEFT_"+$case)} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow ("MAIN_PROCESS_RUNNING_"+$case)} +} +if($build.Commit -cne $env:GITHUB_SHA -or $build.Version -cne "1.7.6" -or + $build.Private -ne $true -or $build.NotForPublicInstallation -ne $true){ + StopNow "WRONG_PRIVATE_CANDIDATE_MANIFEST" +} +if((Hash $setup) -cne $build.SetupSHA256){StopNow "PRIVATE_COMPILED_SETUP_SHA_DRIFT"} +if((Test-Path $app) -or (Test-Path $reg) -or (Test-Path $legacy) -or + (Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + StopNow "GUEST_NOT_CLEAN" +} +[void](New-Item -Path $out -ItemType Directory -ErrorAction Stop) +try{ + $phase="FRESH_TEST_ONLY_SETUP" + $installedExit=RunCandidate "/install /quiet /desktop=0 /startup=0" + if($installedExit -ne 0){StopNow ("FRESH_INSTALL_EXIT_"+$installedExit)} + if((Hash (Join-Path $app "TaskbarMonitorEnhanced.exe")) -cne $build.MainSHA256 -or + (Hash (Join-Path $app "Uninstall.exe")) -cne $build.SetupSHA256){ + StopNow "FRESH_INSTALL_PRODUCT_SHA_CHANGED" + } + $source=Join-Path $app "Source/TaskbarMonitorEnhanced.cs" + $redundant=Join-Path $app "TaskbarMonitorEnhanced.cs" + if((Hash $source) -ceq "MISSING"){StopNow "SINGLE_MANAGED_SOURCE_NOT_INSTALLED"} + if(Test-Path $redundant){StopNow "REDUNDANT_ROOT_SOURCE_STILL_INSTALLED"} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow "FRESH_MAIN_ALREADY_RUNNING"} + $phase="CREATE_USER_OWNED_TEST_FIXTURE" + $config=Join-Path $app "config.json" + $backup=Join-Path $app "config.json.bak" + $canary=Join-Path $app "R273_PRESERVE_USER_CANARY.txt" + if(!(Test-Path $config)){ + [IO.File]::WriteAllText($config,'{"GlassMode":true,"Opacity":0,"OpacityWhenGlassOff":0.1,"R273UserFixture":"retain all bytes"}',[Text.UTF8Encoding]::new($false)) + } + if(!(Test-Path $backup)){ + [IO.File]::WriteAllText($backup,'{"R273Backup":"independent prior user config must remain SHA-exact"}',[Text.UTF8Encoding]::new($false)) + } + [IO.File]::WriteAllText($canary,"R273 synthetic guest user canary, no personal content",[Text.UTF8Encoding]::new($false)) + $originalRun=StartupValue + $original=Snapshot + if(@($original.Values|Where-Object {$_ -ceq "MISSING"}).Count){StopNow "EXPECTED_FRESH_FIXTURE_MISSING"} + + $phase="FIRST_SAME_VERSION_MAINTENANCE_IDEMPOTENCE" + $sameVersionExit=RunCandidate "/update /quiet" + if($sameVersionExit -ne 0){StopNow ("SAME_VERSION_MAINTENANCE_FAILED_"+$sameVersionExit)} + AssertUnchanged $original "FIRST_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "MAINTENANCE_RECREATED_REDUNDANT_SOURCE"} + + $phase="REJECT_DISPOSABLE_PROGRAMFILES_DUPLICATE" + if(Test-Path $legacy){StopNow "LEGACY_PATH_PREEXISTS"} + [void](New-Item $legacy -ItemType Directory -ErrorAction Stop) + $owned=$true + [IO.File]::WriteAllText($marker,("R273 inert duplicate executable marker "+$HostLabel),[Text.UTF8Encoding]::new($false)) + $expectedMarker=Hash $marker + if($expectedMarker -ceq "MISSING"){StopNow "LEGACY_INERT_MARKER_MISSING"} + $negativeExit=RunCandidate "/update /quiet" + if($negativeExit -ne 34){StopNow ("PROGRAMFILES_DUPLICATE_NOT_EXACTLY_REFUSED_"+$negativeExit)} + if((Hash $marker) -cne $expectedMarker){StopNow "INERT_MARKER_MODIFIED"} + AssertUnchanged $original "PROGRAMFILES_CONFLICT_REFUSAL" + if((Hash $marker) -cne $expectedMarker){StopNow "MARKER_CHANGED_BEFORE_REMOVAL"} + Remove-Item -LiteralPath $marker -Force -ErrorAction Stop + if(@(Get-ChildItem -LiteralPath $legacy -Force).Count){StopNow "LEGACY_DIR_CONTENT_NOT_OWNED"} + Remove-Item -LiteralPath $legacy -Force -ErrorAction Stop + $owned=$false + if(Test-Path $legacy){StopNow "LEGACY_DIR_NOT_REMOVED"} + $removed=$true + + $phase="SECOND_IDEMPOTENT_UPDATE_AFTER_CONFLICT_REMOVAL" + $afterCollisionExit=RunCandidate "/update /quiet" + if($afterCollisionExit -ne 0){StopNow ("AFTER_COLLISION_MAINTENANCE_FAILED_"+$afterCollisionExit)} + AssertUnchanged $original "SECOND_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "ROOT_REDUNDANCY_RETURNED"} + $status="PASS_REAL_GUEST_FRESH_R273_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT" +}catch{ + $error=$_.Exception.GetType().Name+": "+$_.Exception.Message +}finally{ + if($owned -and (Test-Path $marker) -and (Hash $marker) -ceq $expectedMarker){ + Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue + } + if($owned -and (Test-Path $legacy) -and @((Get-ChildItem -LiteralPath $legacy -Force -ErrorAction SilentlyContinue)).Count -eq 0){ + Remove-Item -LiteralPath $legacy -Force -ErrorAction SilentlyContinue + } + $e=[ordered]@{ + Revision="R273";Status=$status;Phase=$phase;Failure=$error;Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;TestOnlyCandidate=$true;PublicSetupChanged=$false + PersonalComputerTouched=$false;ReleasedV173Untouched=$true + CandidateMainSHA256=$build.MainSHA256;CandidateSetupSHA256=$build.SetupSHA256 + FreshInstallExit=$installedExit + SingleManagedSourceWithoutRootDuplicate=(!(Test-Path (Join-Path $app "TaskbarMonitorEnhanced.cs"))) + FirstSameVersionMaintenanceExit=$sameVersionExit + NegativeProgramFilesCollisionExit=$negativeExit + SecondSameVersionMaintenanceExit=$afterCollisionExit + GuestOwnedLegacyFixtureRemoved=(!(Test-Path $legacy)) + ProtectedCanaryAndConfigPreserved=($status -like "PASS_*") + RealHKLMRegistryOrCrossUserAcceptance=$false + NoPhysicalPowerCutOrExternalSecurityClaim=$true + UTC=[DateTime]::UtcNow.ToString("o") + } + [IO.File]::WriteAllText($receipt,($e|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($e|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike "PASS_*"){throw ("R273_REAL_GUEST_NOT_ACCEPTED "+$phase+" "+$error)} diff --git a/build/Test-R284-RestoreResidue-NativeRecovery.ps1 b/build/Test-R284-RestoreResidue-NativeRecovery.ps1 new file mode 100644 index 00000000..3b6a05f6 --- /dev/null +++ b/build/Test-R284-RestoreResidue-NativeRecovery.ps1 @@ -0,0 +1,304 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Never execute an installer or kill a process on a personal/shared computer. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + ![Environment]::UserInteractive){throw 'R284_ONLY_NEW_DISPOSABLE_GITHUB_WINDOWS_VM_ALLOWED'} +Add-Type -AssemblyName System.IO.Compression.FileSystem +Add-Type -TypeDefinition 'using System.IO; public static class R284Bridge { public static void ReplaceNoBackup(string src,string dest) { File.Replace(src,dest,null); } }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$published176='D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0' +$publishedMain176='0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8' +$libSha='6EBC194316536BA61AF5BE24508AD9FCBB2ECC685E716C12E787C79530F66BF0' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$uninstall='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$out=Join-Path $env:RUNNER_TEMP ('R284_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $out 'R284_RECOVERY_RECEIPT.json' +$arm=Join-Path $out 'ARMED.json' +$childFile=Join-Path $out 'StageChild.ps1' +$predecessor=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$candidate=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$candidateMain=Join-Path $CandidateDir 'App\TaskbarMonitorEnhanced.exe' +$candidateManifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prevFixture=Join-Path $PSScriptRoot 'official-v176-managed.json' +$native=$null;$child=$null;$watcher=$null +$nativeWasKilled=$false;$documentWitness=$false;$residueInjected=$false +$phaseNow='INITIAL';$reason='';$accepted=$false +$gates=[Collections.Generic.List[string]]::new() +function Hash([string]$path){ + if(Test-Path -LiteralPath $path -PathType Leaf){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} + return 'MISSING' +} +function ProcessCount(){return @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count} +function StartupValue(){ + $key=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $key){return 'MISSING'} + $p=$key.PSObject.Properties['TaskbarMonitorEnhanced'] + return $(if($null -eq $p){'MISSING'}else{[string]$p.Value}) +} +function Run-Official([string]$exe,[string]$arguments){ + if($arguments -cne '/update /quiet'){throw 'R284_TEST_REFUSES_WRONG_ARGUMENTS'} + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$exe;$si.Arguments=$arguments;$si.UseShellExecute=$false + $si.WorkingDirectory=(Split-Path $exe -Parent) + $p=[Diagnostics.Process]::Start($si) + if(!$p){throw 'R284_SETUP_NOT_LAUNCHED'} + try{ + if(!$p.WaitForExit(150000)){ + $p.Refresh() + if(!$p.HasExited){$p.Kill();[void]$p.WaitForExit(14000)} + throw 'R284_NATIVE_SETUP_TIMEOUT_ONLY_TEST_PROCESS_STOPPED' + } + return [int]$p.ExitCode + }finally{$p.Dispose()} +} +if((Test-Path $app) -or (Test-Path $uninstall) -or + (Test-Path $out) -or (ProcessCount) -ne 0){throw 'R284_GUEST_NOT_CLEAN'} +[IO.Directory]::CreateDirectory($out)|Out-Null +try{ + $phaseNow='PIN_CANDIDATE_AND_IMMUTABLE_PREDECESSOR' + $manifest=Get-Content $candidateManifest -Raw|ConvertFrom-Json + if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.7' -or + $manifest.Private -ne $true -or $manifest.NotForPublicInstallation -ne $true -or + (Hash $candidate) -cne $manifest.SetupSHA256 -or + (Hash $candidateMain) -cne $manifest.MainSHA256){throw 'R284_CANDIDATE_SOURCE_OR_SHA_MISMATCH'} + $candidateSha=[string]$manifest.SetupSHA256 + $mainSha=[string]$manifest.MainSHA256 + if($candidateSha -cnotmatch '^[A-F0-9]{64}$' -or $mainSha -cnotmatch '^[A-F0-9]{64}$'){throw 'R284_CANDIDATE_BAD_DIGEST'} + $env:GH_PROMPT_DISABLED='1' + & gh release download v1.7.6 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.6.exe -D $out + if($LASTEXITCODE -ne 0 -or (Hash $predecessor) -cne $published176){throw 'R284_PREVIOUS_OFFICIAL_RELEASE_DOWNLOAD_HASH_FAILED'} + $pin=Get-Content $prevFixture -Raw|ConvertFrom-Json + if($pin.Version -cne '1.7.6' -or $pin.ManagedCount -ne 22 -or + @($pin.Rows).Count -ne 22 -or $pin.PublishedSetupSHA256 -cne $published176 -or + $pin.PublishedMainSHA256 -cne $publishedMain176){throw 'R284_BAD_PRIOR_MANIFEST'} + $gates.Add('BOTH_OFFICIAL_AND_PRIVATE_CANDIDATE_ASSET_SHA_VERIFIED') + $phaseNow='RESTORE_PINNED_V176_GUEST_ROOT_WITHOUT_RUNNING_OLD_SETUP' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($predecessor)) + [IO.Directory]::CreateDirectory($app)|Out-Null + foreach($row in $pin.Rows){ + $rel=[string]$row.Path + if(!$rel -or [IO.Path]::IsPathRooted($rel) -or + $rel -match '(^|[/\\])\.\.([/\\]|$)' -or $rel -match '[:*?"<>|]'){ + throw 'R284_UNSAFE_PRIOR_RESOURCE_PATH' + } + $dest=[IO.Path]::GetFullPath((Join-Path $app $rel)) + if(!$dest.StartsWith(([IO.Path]::GetFullPath($app).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R284_PREDECESSOR_RESOURCE_TRAVERSAL' + } + [IO.Directory]::CreateDirectory((Split-Path $dest -Parent))|Out-Null + if(Test-Path $dest){throw 'R284_DUPLICATE_PREVIOUS_MANAGED_RESOURCE'} + if($rel -ceq 'Uninstall.exe'){ + [IO.File]::Copy($predecessor,$dest) + }else{ + $embedded='Payload.'+[IO.Path]::GetFileName($rel) + $source=$asm.GetManifestResourceStream($embedded) + if(!$source){throw ('R284_PREVIOUS_EMBEDDED_RESOURCE_MISSING_'+$embedded)} + try{ + $target=[IO.File]::Open($dest,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$source.CopyTo($target)}finally{$target.Dispose()} + }finally{$source.Dispose()} + } + if((Hash $dest) -cne $row.SHA256 -or (Get-Item $dest).Length -ne $row.Bytes){ + throw ('R284_PREVIOUS_MANAGED_SHA_OR_LENGTH_WRONG_'+$rel) + } + } + $backendRoot=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6' + $lhmArchive=Join-Path $out 'LibreHardwareMonitor-from-official-Setup.zip' + $stream=$asm.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$stream){throw 'R284_PRIOR_OFFICIAL_LHM_MISSING'} + try{ + $dst=[IO.File]::Open($lhmArchive,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($dst)}finally{$dst.Dispose()} + }finally{$stream.Dispose()} + [IO.Directory]::CreateDirectory($backendRoot)|Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory($lhmArchive,$backendRoot) + if((Hash (Join-Path $backendRoot 'LibreHardwareMonitorLib.dll')) -cne $libSha){throw 'R284_LIBRARY_SHA_MISMATCH'} + $stateJson='{"App":"Taskbar Monitor Enhanced","Version":"1.7.6","PublicVersion":"1.7.6","ProductIdentity":"LOCKED","SensorLayerStatus":"READY","SensorLayerMode":"R284_PRIVATE_PINNED_FIXTURE","SensorLayerVersion":"0.9.6","R284Fixture":true}' + [IO.File]::WriteAllText((Join-Path $app 'install_state.json'),$stateJson,[Text.UTF8Encoding]::new($false)) + $reg=New-Item -Path $uninstall -Force -ErrorAction Stop + Set-ItemProperty -Path $uninstall -Name DisplayName -Value 'Taskbar Monitor Enhanced' + Set-ItemProperty -Path $uninstall -Name DisplayVersion -Value '1.7.6' + Set-ItemProperty -Path $uninstall -Name InstallLocation -Value $app + $reg.Dispose() + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $publishedMain176 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $published176 -or + (ProcessCount) -ne 0){throw 'R284_PINNED_PREDECESSOR_NOT_VALID'} + $protected=@{} + $config=Join-Path $app 'config.json' + $backup=Join-Path $app 'config.json.bak' + $canary=Join-Path $app 'R284_PRIVATE_USER_CANARY.txt' + [IO.File]::WriteAllText($config,'{"Theme":"Retro Terminal","GlassMode":true,"Opacity":0,"EnableTemperatureAlerts":true,"EnableTemperatureNotifications":true,"R284":"PRESERVE"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"BackupCanary":"R284_PROTECTED"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($canary,'Only disposable R284 user content',[Text.UTF8Encoding]::new($false)) + foreach($p in @($config,$backup,$canary,(Join-Path $backendRoot 'LibreHardwareMonitorLib.dll'))){$protected[$p]=Hash $p} + $startup=StartupValue + $gates.Add('22_SHA_PINNED_PREDECESSOR_ROWS_AND_PROTECTED_SETTINGS') + $phaseNow='EXACT_CANDIDATE_STAGE_HELPER' + $childText=@' +param([string]$Setup,[string]$App,[string]$Signal) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows'){throw 'R284_CHILD_REFUSES_NONHOSTED'} +if((Get-FileHash $Setup).Hash -cne $env:R284_CANDIDATE_SETUP_SHA){throw 'R284_CHILD_CANDIDATE_SHA_DRIFT'} +if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -cne '0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8'){throw 'R284_CHILD_PREVIOUS_SHA_DRIFT'} +if(Test-Path (Join-Path $App '.tbme-upgrade')){throw 'R284_CHILD_STAGE_ALREADY_EXISTS'} +Add-Type -TypeDefinition 'using System.IO; public static class R284FileSwap { public static void ReplaceOne(string a,string b){File.Replace(a,b,null);} }' +$assembly=[Reflection.Assembly]::LoadFrom($Setup) +$type=$assembly.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,NonPublic,Public' +$old='1.7.6';$channel='OFFICIAL_STABLE_V176' +$prior=[IO.File]::ReadAllText((Join-Path $App 'install_state.json')) +$legacy=$type.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@($old,$channel)) +$fresh=$type.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($prior)) +$fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($Setup) +[void]$type.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$prior,$old,$channel)) +[void]$type.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) +$stage=Join-Path $App '.tbme-upgrade\new' +$mainLive=Join-Path $App 'TaskbarMonitorEnhanced.exe' +[R284FileSwap]::ReplaceOne((Join-Path $stage 'TaskbarMonitorEnhanced.exe'),$mainLive) +$note='Docs/RELEASE_NOTES_v1.7.7.md' +$live=Join-Path $App $note +if(Test-Path $live){throw 'NEW_NOTE_ALREADY_EXISTS'} +[IO.File]::Move((Join-Path $stage $note),$live) +if((Get-FileHash $mainLive).Hash -cne $env:R284_CANDIDATE_MAIN_SHA){throw 'R284_PARTIAL_CANDIDATE_MAIN_NOT_PRESENT'} +[IO.File]::WriteAllText($Signal,([ordered]@{ChildPid=$PID;Phase='COMMITTING';ReplacedManagedCount=2;Utc=[datetime]::UtcNow.ToString('o')}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) +while($true){Start-Sleep -Seconds 2} +'@ + [IO.File]::WriteAllText($childFile,$childText,[Text.UTF8Encoding]::new($false)) + $env:R284_CANDIDATE_SETUP_SHA=$candidateSha + $env:R284_CANDIDATE_MAIN_SHA=$mainSha + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $si.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$childFile+'" -Setup "'+$candidate+'" -App "'+$app+'" -Signal "'+$arm+'"' + $si.UseShellExecute=$false;$si.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($si) + if(!$child){throw 'R284_CHILD_NOT_STARTED'} + $clock=[Diagnostics.Stopwatch]::StartNew() + while(!(Test-Path $arm) -and $clock.Elapsed.TotalSeconds -lt 55){ + $child.Refresh() + if($child.HasExited){throw ('R284_CHILD_FAIL_EXIT_'+$child.ExitCode)} + Start-Sleep -Milliseconds 75 + } + if(!(Test-Path $arm)){throw 'R284_CHILD_DID_NOT_ARM'} + $message=Get-Content $arm -Raw|ConvertFrom-Json + if($message.ChildPid -ne $child.Id -or $message.Phase -cne 'COMMITTING' -or $message.ReplacedManagedCount -ne 2){throw 'R284_ARMED_INVALID'} + $phase=Join-Path $stage 'phase.txt' + $currentNote=Join-Path $app 'Docs\RELEASE_NOTES_v1.7.7.md' + if((Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING' -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or !(Test-Path $currentNote)){ + throw 'R284_STAGE_NOT_PHYSICALLY_PARTIAL' + } + $child.Refresh();if($child.HasExited){throw 'R284_CHILD_EXITED_ALREADY'} + $child.Kill();if(!$child.WaitForExit(13000)){throw 'R284_CHILD_KILL_NOT_COMPLETE'} + $child.Dispose();$child=$null + $gates.Add('ACTUAL_ISOLATED_STAGING_HELPER_KILLED_WITH_TWO_MANAGED_REPLACEMENTS') + $residue=Join-Path $stage 'old\TaskbarMonitorEnhanced.exe.restore' + if(Test-Path $residue){throw 'R284_STRANDED_RESIDUE_ALREADY_EXISTS'} + if($HostLabel -ceq 'B'){ + $phaseNow='KILL_ACTUAL_OFFICIAL_CANDIDATE_SETUP_PROCESS_DURING_ROLLBACK' + $watcher=[IO.FileSystemWatcher]::new((Join-Path $app 'Docs'),'RELEASE_NOTES_v1.7.7.md') + $watcher.NotifyFilter=[IO.NotifyFilters]'FileName,LastWrite,Size,CreationTime' + $watcher.EnableRaisingEvents=$true + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$candidate;$si.Arguments='/update /quiet';$si.UseShellExecute=$false;$si.WorkingDirectory=$out + $native=[Diagnostics.Process]::Start($si) + if(!$native){throw 'R284_REAL_SETUP_NOT_STARTED'} + $watchTime=[Diagnostics.Stopwatch]::StartNew() + while($watchTime.Elapsed.TotalSeconds -lt 40){ + $native.Refresh() + if($native.HasExited){throw ('R284_SETUP_FINISHED_BEFORE_KILLABLE_ROLLBACK_'+$native.ExitCode)} + $signal=$watcher.WaitForChanged([IO.WatcherChangeTypes]::All,45) + if($signal.TimedOut -or (Test-Path $currentNote)){continue} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + !(Test-Path $phase) -or (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R284_REAL_ROLLBACK_KILL_WINDOW_WAS_MISSED' + } + $native.Refresh();if($native.HasExited){throw 'R284_REAL_SETUP_EXITED_BEFORE_FORCE_TERMINATION'} + $native.Kill() + if(!$native.WaitForExit(18000)){throw 'R284_REAL_NATIVE_SETUP_DID_NOT_TERMINATE'} + $nativeWasKilled=$true;$documentWitness=$true + break + } + if(!$nativeWasKilled){throw 'R284_NO_VALID_REAL_SETUP_PROCESS_KILL_WITNESS'} + $watcher.EnableRaisingEvents=$false;$watcher.Dispose();$watcher=$null + $native.Dispose();$native=$null + if(!(Test-Path $stage) -or (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha){throw 'R284_KILL_NOT_LEFT_PARTIAL_STAGE'} + $gates.Add('REAL_UNMODIFIED_CANDIDATE_SETUP_PROCESS_KILLED_AFTER_DOCUMENT_DELETION') + } + $phaseNow='EXACT_KNOWN_OWNER_STAGE_STRANDED_TEMP_RESIDUE' + if(!(Test-Path $residue)){ + $copy=[IO.File]::Open($residue,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + $copy.Dispose() + $residueInjected=$true + } + if((Get-Item $residue).Length -ne 0 -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R284_NOT_A_REPRODUCIBLE_STRANDED_ZERO_BYTE_OLD_MAIN_SCRATCH' + } + $gates.Add('ZERO_LENGTH_STRANDED_UPDATER_OWNED_RESTORE_FILE_CONFIRMED') + $phaseNow='NEW_VERSIONED_OFFICIAL_SETUP_COLD_RECOVERS' + $result=Run-Official $candidate '/update /quiet' + if($result -ne 0){throw ('R284_CANDIDATE_FAILED_TO_COLD_RECOVER_'+$result)} + if(Test-Path $stage){throw 'R284_JOURNAL_NOT_REMOVED'} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $candidateSha -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.7'){throw 'R284_NEW_INSTALLED_APP_OR_SETUP_HASH_INVALID'} + $newState=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($newState.Version -cne '1.7.7' -or $newState.PublicVersion -cne '1.7.7'){throw 'R284_INSTALLED_PUBLIC_VERSION_INCORRECT'} + if((Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.6.md')) -or !(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.7.md'))){throw 'R284_VERSION_NOTES_UPGRADE_INVALID'} + foreach($p in $protected.Keys){if((Hash $p) -cne $protected[$p]){throw ('R284_PROTECTED_USER_FILE_CHANGED_'+$p)}} + if((StartupValue) -cne $startup){throw 'R284_USER_AUTOSTART_PREFERENCE_CHANGED'} + if((ProcessCount) -ne 0){throw 'R284_MAIN_PROGRAM_UNEXPECTEDLY_LAUNCHED'} + if(@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced').Count -ne 1){throw 'R284_MORE_THAN_ONE_PRODUCT_REGISTRATION'} + $gates.Add('EXACT_RELEASE_177_MAIN_SETUP_USER_CONFIG_AUTOSTART_ONE_ROOT_JOURNAL_REMOVED') + $result=[ordered]@{ + Schema=1;Revision='R284';Status='PASS_EXACT_V177_SETUP_RETRY_AFTER_STRANDED_RESTORE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;PreviousOfficialVersion='1.7.6' + PreviousOfficialSetupSHA256=$published176;PreviousOfficialMainSHA256=$publishedMain176 + PreviousManifestRowsSHAAndSizeChecked=22;PredecessorSetupProcessRun=$false;PrivilegedDriverInstalled=$false + CandidateVersion='1.7.7';CandidateSetupSHA256=$candidateSha;CandidateMainSHA256=$mainSha + StagedPhase='COMMITTING';ManagedFileReplacementsBeforeKill=2 + StagingHelperActuallyKilled=$true;NativeSetupProcessActuallyKilled=$nativeWasKilled + NativeSetupManagedNoteDeletionWitnessed=$documentWitness + StrandedUpdaterOwnedRestoreZeroByteProven=$true + StrandedScratchInjectedOnlyIfAbsent=$residueInjected + RecoveredAfterSecondNativeSetupRun=$true;ProtectedUserDataHashPreserved=$true + SingleCanonicalUserRootPreserved=$true;JournalRemoved=$true + PhysicalMainsLossTriggered=$false;OriginalOwnerComputerTouched=$false + PassedGates=@($gates);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R284_DISPOSABLE_GUEST_'+$HostLabel+'_INTERRUPTED_SETUP_RECOVERY=PASS' +}catch{ + $reason=$_.Exception.ToString() + Write-Output ('R284_GUEST_PHASE_FAILURE='+$phaseNow+' REASON='+$reason) + throw +}finally{ + if($watcher){$watcher.EnableRaisingEvents=$false;$watcher.Dispose()} + if($native){ + try{$native.Refresh();if(!$native.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$native.Kill();[void]$native.WaitForExit(12000)}}finally{$native.Dispose()} + } + if($child){ + try{$child.Refresh();if(!$child.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$child.Kill();[void]$child.WaitForExit(12000)}}finally{$child.Dispose()} + } + if(!$accepted){ + $bad=[ordered]@{Schema=1;Revision='R284';Status='FAIL';Host=$HostLabel;Phase=$phaseNow + Failure=$reason;NativeSetupWasKilled=$nativeWasKilled;WitnessedNativeDeletion=$documentWitness + StrandedResidueInjected=$residueInjected;UserHostTouched=$false;Utc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($bad|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R286-RestoreResidue-NativeRecovery.ps1 b/build/Test-R286-RestoreResidue-NativeRecovery.ps1 new file mode 100644 index 00000000..822c7fc1 --- /dev/null +++ b/build/Test-R286-RestoreResidue-NativeRecovery.ps1 @@ -0,0 +1,304 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Never execute an installer or kill a process on a personal/shared computer. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + ![Environment]::UserInteractive){throw 'R286_ONLY_NEW_DISPOSABLE_GITHUB_WINDOWS_VM_ALLOWED'} +Add-Type -AssemblyName System.IO.Compression.FileSystem +Add-Type -TypeDefinition 'using System.IO; public static class R286Bridge { public static void ReplaceNoBackup(string src,string dest) { File.Replace(src,dest,null); } }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$published177='395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D' +$publishedMain177='A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253' +$libSha='6EBC194316536BA61AF5BE24508AD9FCBB2ECC685E716C12E787C79530F66BF0' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$uninstall='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$out=Join-Path $env:RUNNER_TEMP ('R286_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $out 'R286_RECOVERY_RECEIPT.json' +$arm=Join-Path $out 'ARMED.json' +$childFile=Join-Path $out 'StageChild.ps1' +$predecessor=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$candidate=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$candidateMain=Join-Path $CandidateDir 'App\TaskbarMonitorEnhanced.exe' +$candidateManifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prevFixture=Join-Path $PSScriptRoot 'official-v177-managed.json' +$native=$null;$child=$null;$watcher=$null +$nativeWasKilled=$false;$documentWitness=$false;$residueInjected=$false +$phaseNow='INITIAL';$reason='';$accepted=$false +$gates=[Collections.Generic.List[string]]::new() +function Hash([string]$path){ + if(Test-Path -LiteralPath $path -PathType Leaf){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} + return 'MISSING' +} +function ProcessCount(){return @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count} +function StartupValue(){ + $key=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $key){return 'MISSING'} + $p=$key.PSObject.Properties['TaskbarMonitorEnhanced'] + return $(if($null -eq $p){'MISSING'}else{[string]$p.Value}) +} +function Run-Official([string]$exe,[string]$arguments){ + if($arguments -cne '/update /quiet'){throw 'R286_TEST_REFUSES_WRONG_ARGUMENTS'} + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$exe;$si.Arguments=$arguments;$si.UseShellExecute=$false + $si.WorkingDirectory=(Split-Path $exe -Parent) + $p=[Diagnostics.Process]::Start($si) + if(!$p){throw 'R286_SETUP_NOT_LAUNCHED'} + try{ + if(!$p.WaitForExit(150000)){ + $p.Refresh() + if(!$p.HasExited){$p.Kill();[void]$p.WaitForExit(14000)} + throw 'R286_NATIVE_SETUP_TIMEOUT_ONLY_TEST_PROCESS_STOPPED' + } + return [int]$p.ExitCode + }finally{$p.Dispose()} +} +if((Test-Path $app) -or (Test-Path $uninstall) -or + (Test-Path $out) -or (ProcessCount) -ne 0){throw 'R286_GUEST_NOT_CLEAN'} +[IO.Directory]::CreateDirectory($out)|Out-Null +try{ + $phaseNow='PIN_CANDIDATE_AND_IMMUTABLE_PREDECESSOR' + $manifest=Get-Content $candidateManifest -Raw|ConvertFrom-Json + if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.8' -or + $manifest.Private -ne $true -or $manifest.NotForPublicInstallation -ne $true -or + (Hash $candidate) -cne $manifest.SetupSHA256 -or + (Hash $candidateMain) -cne $manifest.MainSHA256){throw 'R286_CANDIDATE_SOURCE_OR_SHA_MISMATCH'} + $candidateSha=[string]$manifest.SetupSHA256 + $mainSha=[string]$manifest.MainSHA256 + if($candidateSha -cnotmatch '^[A-F0-9]{64}$' -or $mainSha -cnotmatch '^[A-F0-9]{64}$'){throw 'R286_CANDIDATE_BAD_DIGEST'} + $env:GH_PROMPT_DISABLED='1' + & gh release download v1.7.7 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.7.exe -D $out + if($LASTEXITCODE -ne 0 -or (Hash $predecessor) -cne $published177){throw 'R286_PREVIOUS_OFFICIAL_RELEASE_DOWNLOAD_HASH_FAILED'} + $pin=Get-Content $prevFixture -Raw|ConvertFrom-Json + if($pin.Version -cne '1.7.7' -or $pin.ManagedCount -ne 22 -or + @($pin.Rows).Count -ne 22 -or $pin.PublishedSetupSHA256 -cne $published177 -or + $pin.PublishedMainSHA256 -cne $publishedMain177){throw 'R286_BAD_PRIOR_MANIFEST'} + $gates.Add('BOTH_OFFICIAL_AND_PRIVATE_CANDIDATE_ASSET_SHA_VERIFIED') + $phaseNow='RESTORE_PINNED_V176_GUEST_ROOT_WITHOUT_RUNNING_OLD_SETUP' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($predecessor)) + [IO.Directory]::CreateDirectory($app)|Out-Null + foreach($row in $pin.Rows){ + $rel=[string]$row.Path + if(!$rel -or [IO.Path]::IsPathRooted($rel) -or + $rel -match '(^|[/\\])\.\.([/\\]|$)' -or $rel -match '[:*?"<>|]'){ + throw 'R286_UNSAFE_PRIOR_RESOURCE_PATH' + } + $dest=[IO.Path]::GetFullPath((Join-Path $app $rel)) + if(!$dest.StartsWith(([IO.Path]::GetFullPath($app).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R286_PREDECESSOR_RESOURCE_TRAVERSAL' + } + [IO.Directory]::CreateDirectory((Split-Path $dest -Parent))|Out-Null + if(Test-Path $dest){throw 'R286_DUPLICATE_PREVIOUS_MANAGED_RESOURCE'} + if($rel -ceq 'Uninstall.exe'){ + [IO.File]::Copy($predecessor,$dest) + }else{ + $embedded='Payload.'+[IO.Path]::GetFileName($rel) + $source=$asm.GetManifestResourceStream($embedded) + if(!$source){throw ('R286_PREVIOUS_EMBEDDED_RESOURCE_MISSING_'+$embedded)} + try{ + $target=[IO.File]::Open($dest,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$source.CopyTo($target)}finally{$target.Dispose()} + }finally{$source.Dispose()} + } + if((Hash $dest) -cne $row.SHA256 -or (Get-Item $dest).Length -ne $row.Bytes){ + throw ('R286_PREVIOUS_MANAGED_SHA_OR_LENGTH_WRONG_'+$rel) + } + } + $backendRoot=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6' + $lhmArchive=Join-Path $out 'LibreHardwareMonitor-from-official-Setup.zip' + $stream=$asm.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$stream){throw 'R286_PRIOR_OFFICIAL_LHM_MISSING'} + try{ + $dst=[IO.File]::Open($lhmArchive,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($dst)}finally{$dst.Dispose()} + }finally{$stream.Dispose()} + [IO.Directory]::CreateDirectory($backendRoot)|Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory($lhmArchive,$backendRoot) + if((Hash (Join-Path $backendRoot 'LibreHardwareMonitorLib.dll')) -cne $libSha){throw 'R286_LIBRARY_SHA_MISMATCH'} + $stateJson='{"App":"Taskbar Monitor Enhanced","Version":"1.7.7","PublicVersion":"1.7.7","ProductIdentity":"LOCKED","SensorLayerStatus":"READY","SensorLayerMode":"R286_PRIVATE_PINNED_FIXTURE","SensorLayerVersion":"0.9.6","R286Fixture":true}' + [IO.File]::WriteAllText((Join-Path $app 'install_state.json'),$stateJson,[Text.UTF8Encoding]::new($false)) + $reg=New-Item -Path $uninstall -Force -ErrorAction Stop + Set-ItemProperty -Path $uninstall -Name DisplayName -Value 'Taskbar Monitor Enhanced' + Set-ItemProperty -Path $uninstall -Name DisplayVersion -Value '1.7.7' + Set-ItemProperty -Path $uninstall -Name InstallLocation -Value $app + $reg.Dispose() + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $publishedMain177 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $published177 -or + (ProcessCount) -ne 0){throw 'R286_PINNED_PREDECESSOR_NOT_VALID'} + $protected=@{} + $config=Join-Path $app 'config.json' + $backup=Join-Path $app 'config.json.bak' + $canary=Join-Path $app 'R286_PRIVATE_USER_CANARY.txt' + [IO.File]::WriteAllText($config,'{"Theme":"Retro Terminal","GlassMode":true,"Opacity":0,"EnableTemperatureAlerts":true,"EnableTemperatureNotifications":true,"R286":"PRESERVE"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"BackupCanary":"R286_PROTECTED"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($canary,'Only disposable R286 user content',[Text.UTF8Encoding]::new($false)) + foreach($p in @($config,$backup,$canary,(Join-Path $backendRoot 'LibreHardwareMonitorLib.dll'))){$protected[$p]=Hash $p} + $startup=StartupValue + $gates.Add('22_SHA_PINNED_PREDECESSOR_ROWS_AND_PROTECTED_SETTINGS') + $phaseNow='EXACT_CANDIDATE_STAGE_HELPER' + $childText=@' +param([string]$Setup,[string]$App,[string]$Signal) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows'){throw 'R286_CHILD_REFUSES_NONHOSTED'} +if((Get-FileHash $Setup).Hash -cne $env:R286_CANDIDATE_SETUP_SHA){throw 'R286_CHILD_CANDIDATE_SHA_DRIFT'} +if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -cne 'A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253'){throw 'R286_CHILD_PREVIOUS_SHA_DRIFT'} +if(Test-Path (Join-Path $App '.tbme-upgrade')){throw 'R286_CHILD_STAGE_ALREADY_EXISTS'} +Add-Type -TypeDefinition 'using System.IO; public static class R286FileSwap { public static void ReplaceOne(string a,string b){File.Replace(a,b,null);} }' +$assembly=[Reflection.Assembly]::LoadFrom($Setup) +$type=$assembly.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,NonPublic,Public' +$old='1.7.7';$channel='OFFICIAL_STABLE_V177' +$prior=[IO.File]::ReadAllText((Join-Path $App 'install_state.json')) +$legacy=$type.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@($old,$channel)) +$fresh=$type.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($prior)) +$fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($Setup) +[void]$type.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$prior,$old,$channel)) +[void]$type.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) +$stage=Join-Path $App '.tbme-upgrade\new' +$mainLive=Join-Path $App 'TaskbarMonitorEnhanced.exe' +[R286FileSwap]::ReplaceOne((Join-Path $stage 'TaskbarMonitorEnhanced.exe'),$mainLive) +$note='Docs/RELEASE_NOTES_v1.7.8.md' +$live=Join-Path $App $note +if(Test-Path $live){throw 'NEW_NOTE_ALREADY_EXISTS'} +[IO.File]::Move((Join-Path $stage $note),$live) +if((Get-FileHash $mainLive).Hash -cne $env:R286_CANDIDATE_MAIN_SHA){throw 'R286_PARTIAL_CANDIDATE_MAIN_NOT_PRESENT'} +[IO.File]::WriteAllText($Signal,([ordered]@{ChildPid=$PID;Phase='COMMITTING';ReplacedManagedCount=2;Utc=[datetime]::UtcNow.ToString('o')}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) +while($true){Start-Sleep -Seconds 2} +'@ + [IO.File]::WriteAllText($childFile,$childText,[Text.UTF8Encoding]::new($false)) + $env:R286_CANDIDATE_SETUP_SHA=$candidateSha + $env:R286_CANDIDATE_MAIN_SHA=$mainSha + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $si.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$childFile+'" -Setup "'+$candidate+'" -App "'+$app+'" -Signal "'+$arm+'"' + $si.UseShellExecute=$false;$si.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($si) + if(!$child){throw 'R286_CHILD_NOT_STARTED'} + $clock=[Diagnostics.Stopwatch]::StartNew() + while(!(Test-Path $arm) -and $clock.Elapsed.TotalSeconds -lt 55){ + $child.Refresh() + if($child.HasExited){throw ('R286_CHILD_FAIL_EXIT_'+$child.ExitCode)} + Start-Sleep -Milliseconds 75 + } + if(!(Test-Path $arm)){throw 'R286_CHILD_DID_NOT_ARM'} + $message=Get-Content $arm -Raw|ConvertFrom-Json + if($message.ChildPid -ne $child.Id -or $message.Phase -cne 'COMMITTING' -or $message.ReplacedManagedCount -ne 2){throw 'R286_ARMED_INVALID'} + $phase=Join-Path $stage 'phase.txt' + $currentNote=Join-Path $app 'Docs\RELEASE_NOTES_v1.7.8.md' + if((Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING' -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or !(Test-Path $currentNote)){ + throw 'R286_STAGE_NOT_PHYSICALLY_PARTIAL' + } + $child.Refresh();if($child.HasExited){throw 'R286_CHILD_EXITED_ALREADY'} + $child.Kill();if(!$child.WaitForExit(13000)){throw 'R286_CHILD_KILL_NOT_COMPLETE'} + $child.Dispose();$child=$null + $gates.Add('ACTUAL_ISOLATED_STAGING_HELPER_KILLED_WITH_TWO_MANAGED_REPLACEMENTS') + $residue=Join-Path $stage 'old\TaskbarMonitorEnhanced.exe.restore' + if(Test-Path $residue){throw 'R286_STRANDED_RESIDUE_ALREADY_EXISTS'} + if($HostLabel -ceq 'B'){ + $phaseNow='KILL_ACTUAL_OFFICIAL_CANDIDATE_SETUP_PROCESS_DURING_ROLLBACK' + $watcher=[IO.FileSystemWatcher]::new((Join-Path $app 'Docs'),'RELEASE_NOTES_v1.7.8.md') + $watcher.NotifyFilter=[IO.NotifyFilters]'FileName,LastWrite,Size,CreationTime' + $watcher.EnableRaisingEvents=$true + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$candidate;$si.Arguments='/update /quiet';$si.UseShellExecute=$false;$si.WorkingDirectory=$out + $native=[Diagnostics.Process]::Start($si) + if(!$native){throw 'R286_REAL_SETUP_NOT_STARTED'} + $watchTime=[Diagnostics.Stopwatch]::StartNew() + while($watchTime.Elapsed.TotalSeconds -lt 40){ + $native.Refresh() + if($native.HasExited){throw ('R286_SETUP_FINISHED_BEFORE_KILLABLE_ROLLBACK_'+$native.ExitCode)} + $signal=$watcher.WaitForChanged([IO.WatcherChangeTypes]::All,45) + if($signal.TimedOut -or (Test-Path $currentNote)){continue} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + !(Test-Path $phase) -or (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R286_REAL_ROLLBACK_KILL_WINDOW_WAS_MISSED' + } + $native.Refresh();if($native.HasExited){throw 'R286_REAL_SETUP_EXITED_BEFORE_FORCE_TERMINATION'} + $native.Kill() + if(!$native.WaitForExit(18000)){throw 'R286_REAL_NATIVE_SETUP_DID_NOT_TERMINATE'} + $nativeWasKilled=$true;$documentWitness=$true + break + } + if(!$nativeWasKilled){throw 'R286_NO_VALID_REAL_SETUP_PROCESS_KILL_WITNESS'} + $watcher.EnableRaisingEvents=$false;$watcher.Dispose();$watcher=$null + $native.Dispose();$native=$null + if(!(Test-Path $stage) -or (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha){throw 'R286_KILL_NOT_LEFT_PARTIAL_STAGE'} + $gates.Add('REAL_UNMODIFIED_CANDIDATE_SETUP_PROCESS_KILLED_AFTER_DOCUMENT_DELETION') + } + $phaseNow='EXACT_KNOWN_OWNER_STAGE_STRANDED_TEMP_RESIDUE' + if(!(Test-Path $residue)){ + $copy=[IO.File]::Open($residue,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + $copy.Dispose() + $residueInjected=$true + } + if((Get-Item $residue).Length -ne 0 -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R286_NOT_A_REPRODUCIBLE_STRANDED_ZERO_BYTE_OLD_MAIN_SCRATCH' + } + $gates.Add('ZERO_LENGTH_STRANDED_UPDATER_OWNED_RESTORE_FILE_CONFIRMED') + $phaseNow='NEW_VERSIONED_OFFICIAL_SETUP_COLD_RECOVERS' + $result=Run-Official $candidate '/update /quiet' + if($result -ne 0){throw ('R286_CANDIDATE_FAILED_TO_COLD_RECOVER_'+$result)} + if(Test-Path $stage){throw 'R286_JOURNAL_NOT_REMOVED'} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $candidateSha -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.8'){throw 'R286_NEW_INSTALLED_APP_OR_SETUP_HASH_INVALID'} + $newState=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($newState.Version -cne '1.7.8' -or $newState.PublicVersion -cne '1.7.8'){throw 'R286_INSTALLED_PUBLIC_VERSION_INCORRECT'} + if((Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.7.md')) -or !(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.8.md'))){throw 'R286_VERSION_NOTES_UPGRADE_INVALID'} + foreach($p in $protected.Keys){if((Hash $p) -cne $protected[$p]){throw ('R286_PROTECTED_USER_FILE_CHANGED_'+$p)}} + if((StartupValue) -cne $startup){throw 'R286_USER_AUTOSTART_PREFERENCE_CHANGED'} + if((ProcessCount) -ne 0){throw 'R286_MAIN_PROGRAM_UNEXPECTEDLY_LAUNCHED'} + if(@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced').Count -ne 1){throw 'R286_MORE_THAN_ONE_PRODUCT_REGISTRATION'} + $gates.Add('EXACT_RELEASE_177_MAIN_SETUP_USER_CONFIG_AUTOSTART_ONE_ROOT_JOURNAL_REMOVED') + $result=[ordered]@{ + Schema=1;Revision='R286';Status='PASS_EXACT_V178_SETUP_RETRY_AFTER_STRANDED_RESTORE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;PreviousOfficialVersion='1.7.7' + PreviousOfficialSetupSHA256=$published177;PreviousOfficialMainSHA256=$publishedMain177 + PreviousManifestRowsSHAAndSizeChecked=22;PredecessorSetupProcessRun=$false;PrivilegedDriverInstalled=$false + CandidateVersion='1.7.8';CandidateSetupSHA256=$candidateSha;CandidateMainSHA256=$mainSha + StagedPhase='COMMITTING';ManagedFileReplacementsBeforeKill=2 + StagingHelperActuallyKilled=$true;NativeSetupProcessActuallyKilled=$nativeWasKilled + NativeSetupManagedNoteDeletionWitnessed=$documentWitness + StrandedUpdaterOwnedRestoreZeroByteProven=$true + StrandedScratchInjectedOnlyIfAbsent=$residueInjected + RecoveredAfterSecondNativeSetupRun=$true;ProtectedUserDataHashPreserved=$true + SingleCanonicalUserRootPreserved=$true;JournalRemoved=$true + PhysicalMainsLossTriggered=$false;OriginalOwnerComputerTouched=$false + PassedGates=@($gates);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R286_DISPOSABLE_GUEST_'+$HostLabel+'_INTERRUPTED_SETUP_RECOVERY=PASS' +}catch{ + $reason=$_.Exception.ToString() + Write-Output ('R286_GUEST_PHASE_FAILURE='+$phaseNow+' REASON='+$reason) + throw +}finally{ + if($watcher){$watcher.EnableRaisingEvents=$false;$watcher.Dispose()} + if($native){ + try{$native.Refresh();if(!$native.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$native.Kill();[void]$native.WaitForExit(12000)}}finally{$native.Dispose()} + } + if($child){ + try{$child.Refresh();if(!$child.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$child.Kill();[void]$child.WaitForExit(12000)}}finally{$child.Dispose()} + } + if(!$accepted){ + $bad=[ordered]@{Schema=1;Revision='R286';Status='FAIL';Host=$HostLabel;Phase=$phaseNow + Failure=$reason;NativeSetupWasKilled=$nativeWasKilled;WitnessedNativeDeletion=$documentWitness + StrandedResidueInjected=$residueInjected;UserHostTouched=$false;Utc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($bad|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R290-ReparseRecovery-Native.ps1 b/build/Test-R290-ReparseRecovery-Native.ps1 new file mode 100644 index 00000000..8c4ad6cc --- /dev/null +++ b/build/Test-R290-ReparseRecovery-Native.ps1 @@ -0,0 +1,141 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# Only ephemeral GitHub Windows runners, never the owner's installation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R290_ONLY_GITHUB_DISPOSABLE_WINDOWS' +} +$dir=Join-Path $env:RUNNER_TEMP ('R290_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$alias=Join-Path $app 'R290-Junction-Only-Temporary' +$oldLink=Join-Path $stage 'old' +$outside=Join-Path $dir 'external-canary' +$canary=Join-Path $outside 'do-not-change.txt' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R286_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R286_RECOVERY_RECEIPT.json') +$gates=[Collections.Generic.List[string]]::new() +$accepted=$false +$phase='INITIAL' +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Expect-ReparseRefusal([string]$gate,[Reflection.MethodInfo]$method,[object[]]$arguments){ + $refused=$false + try{[void]$method.Invoke($null,$arguments)} + catch{ + $message=$_.Exception.ToString() + if($message -match '(?i)symlink/junction|reparse'){$refused=$true} + else{throw ('R290_UNEXPECTED_EXCEPTION_'+$gate+': '+$message)} + } + if(!$refused){throw ('R290_FAIL_OPEN_'+$gate)} + $script:gates.Add($gate) +} +if(!(Test-Path $app) -or (Test-Path $dir) -or + (Test-Path $alias) -or (Test-Path $stage) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R290_PREVIOUS_TEST_DID_NOT_LEAVE_CLEAN_GUEST' +} +$previous=Get-Content $prior -Raw|ConvertFrom-Json +$candidate=Get-Content $manifest -Raw|ConvertFrom-Json +if($previous.Status -cne 'PASS_EXACT_V178_SETUP_RETRY_AFTER_STRANDED_RESTORE' -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $candidate.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $candidate.SetupSHA256 -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $candidate.MainSHA256){ + throw 'R290_UNPINNED_CANDIDATE_OR_PRIOR_ACCEPTANCE' +} +$mainBefore=Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe') +$configBefore=Hash (Join-Path $app 'config.json') +$stateBefore=Hash (Join-Path $app 'install_state.json') +[IO.Directory]::CreateDirectory($outside)|Out-Null +[IO.File]::WriteAllText($canary,'R290_EXTERNAL_CANARY_MUST_NOT_CHANGE',[Text.UTF8Encoding]::new($false)) +$canaryBefore=Hash $canary +try{ + $phase='REFLECTION_PREPARE' + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'NonPublic,Static' + $liveMethod=$type.GetMethod('UPath',$flags) + $stageMethod=$type.GetMethod('Stage',$flags) + $recoverMethod=$type.GetMethod('RecoverUpgradeStageIfAny',$flags) + if(!$liveMethod -or !$stageMethod -or !$recoverMethod){throw 'R290_EXPECTED_CANDIDATE_METHODS_MISSING'} + $normal=[string]$liveMethod.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.exe')) + if($normal -cne (Join-Path $app 'TaskbarMonitorEnhanced.exe')){throw 'R290_NORMAL_MANAGED_PATH_CHANGED'} + [void]$stageMethod.Invoke($null,[object[]]@('old','TaskbarMonitorEnhanced.exe')) + $gates.Add('NORMAL_FIXED_APP_AND_STAGE_PATHS_ACCEPTED') + $phase='VALIDATE_TRAVERSAL_REJECTION' + $invalidRejected=$false + try{[void]$stageMethod.Invoke($null,[object[]]@('old','..\..\outside.txt'))} + catch{if($_.Exception.ToString() -match 'unsafe staged resource path|stage escape'){$invalidRejected=$true}else{throw}} + if(!$invalidRejected){throw 'R290_TRAVERSAL_NOT_REJECTED'} + $gates.Add('RELATIVE_TRAVERSAL_REJECTED') + $phase='LIVE_CHILD_JUNCTION' + New-Item -ItemType Junction -Path $alias -Target $outside -ErrorAction Stop|Out-Null + if(-not ((Get-Item -LiteralPath $alias -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R290_REAL_WINDOWS_JUNCTION_NOT_CREATED' + } + Expect-ReparseRefusal 'LIVE_CHILD_JUNCTION_REFUSED' $liveMethod ([object[]]@('R290-Junction-Only-Temporary/do-not-change.txt')) + [IO.Directory]::Delete($alias) + if(Test-Path $alias){throw 'R290_CANNOT_REMOVE_ONLY_LINK'} + $phase='INTERRUPTED_STAGE_CHILD_JUNCTION' + [IO.Directory]::CreateDirectory($stage)|Out-Null + New-Item -ItemType Junction -Path $oldLink -Target $outside -ErrorAction Stop|Out-Null + if(-not ((Get-Item -LiteralPath $oldLink -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R290_REAL_STAGE_JUNCTION_NOT_CREATED' + } + Expect-ReparseRefusal 'STAGED_OLD_JUNCTION_REFUSED' $stageMethod ([object[]]@('old','TaskbarMonitorEnhanced.exe')) + # Reparse refusal must occur before reading any bogus stage metadata. + Expect-ReparseRefusal 'RECOVERY_REJECTS_BEFORE_ANY_FILE_MUTATION' $recoverMethod ([object[]]@()) + [IO.Directory]::Delete($oldLink) + if(Test-Path $oldLink){throw 'R290_STAGE_LINK_CLEANUP_FAILED'} + [IO.Directory]::Delete($stage) + if(Test-Path $stage){throw 'R290_STAGE_CLEANUP_FAILED'} + $phase='CANARY_AND_PROTECTED_ASSET_READBACK' + if((Hash $canary) -cne $canaryBefore -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainBefore -or + (Hash (Join-Path $app 'config.json')) -cne $configBefore -or + (Hash (Join-Path $app 'install_state.json')) -cne $stateBefore){ + throw 'R290_EXTERNAL_OR_PROTECTED_USER_BYTES_ALTERED' + } + $gates.Add('EXTERNAL_AND_INSTALLED_DATA_UNCHANGED') + $gates.Add('NO_ROOT_STAGE_OR_JUNCTION_RESIDUE') + $receipt=[ordered]@{ + Schema=1;Revision='R290';Status='PASS_NATIVE_UPGRADE_CHILD_REPARSE_FAIL_CLOSED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + NativeWindowsJunctionCreated=$true; NormalPathAcceptance=$true + ParentTraversalRejected=$true; LiveChildJunctionRejected=$true + StageOldJunctionRejected=$true; RecoverPreMutationRejected=$true + NoExternalCanaryMutation=$true; InstalledMainAndConfigUnchanged=$true + NoStageOrLinkResidue=$true; Tests=@($gates) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText((Join-Path $dir 'R290_REPARSE_RECEIPT.json'), + ($receipt|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R290_DISPOSABLE_GUEST_'+$HostLabel+'_CHILD_REPARSE_GUARDS_PASS' +}finally{ + if(Test-Path $alias){try{[IO.Directory]::Delete($alias)}catch{}} + if(Test-Path $oldLink){try{[IO.Directory]::Delete($oldLink)}catch{}} + if(Test-Path $stage){try{[IO.Directory]::Delete($stage)}catch{}} + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R290';Status='FAIL';Phase=$phase + SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText((Join-Path $dir 'R290_REPARSE_RECEIPT.json'), + ($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R291-SensorHelperBoundary-Native.ps1 b/build/Test-R291-SensorHelperBoundary-Native.ps1 new file mode 100644 index 00000000..c7ab788f --- /dev/null +++ b/build/Test-R291-SensorHelperBoundary-Native.ps1 @@ -0,0 +1,140 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R291_ONLY_DISPOSABLE_GITHUB_WINDOWS_VM' +} +$out=Join-Path $env:RUNNER_TEMP ('R291_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $out 'R291_BOUNDARY_RECEIPT.json' +$src=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$fixtureApp=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$tempRoot=Join-Path ([Environment]::GetFolderPath('UserProfile')) 'AppData\Local\Temp' +$prior=Join-Path $env:RUNNER_TEMP ('R290_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R290_REPARSE_RECEIPT.json') +$user=[Security.Principal.WindowsIdentity]::GetCurrent().Name +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$live=Join-Path $fixtureApp 'TaskbarMonitorEnhanced.exe' +$log=[Collections.Generic.List[string]]::new() +$link='';$dangling='';$external='';$missing='' +$accepted=$false +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Reject([string]$name,[string]$mode,[string]$payload,[string]$app,[string]$user){ + $failed=$false + try{[void](Assert-SensorPrivilegedBoundary $mode $payload $app $user)} + catch{ + if($_.Exception.Message -notmatch 'SENSOR_BOUNDARY_'){throw ('R291_WRONG_REJECTION_'+$name+': '+$_.Exception.Message)} + $failed=$true + } + if(!$failed){throw ('R291_FAIL_OPEN_'+$name)} + $script:log.Add($name) +} +if(Test-Path $out){throw 'R291_OUTPUT_ALREADY_PRESENT'} +[void][IO.Directory]::CreateDirectory($out) +$mainBefore=Hash $live +try{ + $priorInfo=Get-Content $prior -Raw|ConvertFrom-Json + $identity=Get-Content $manifest -Raw|ConvertFrom-Json + if($priorInfo.Status -cne 'PASS_NATIVE_UPGRADE_CHILD_REPARSE_FAIL_CLOSED' -or + $priorInfo.SourceCommit -cne $env:GITHUB_SHA -or + $identity.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $identity.SetupSHA256 -or + (Hash $live) -cne $identity.MainSHA256){throw 'R291_EXACT_GUEST_CANDIDATE_NOT_PINNED'} + $tokens=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($src,[ref]$tokens,[ref]$errors) + if(@($errors).Count){throw 'R291_PS51_SOURCE_PARSER_FAIL'} + $defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -in @('Assert-SensorBoundaryNoReparse','Assert-SensorPrivilegedBoundary') + },$true)) + if($defs.Count -ne 2){throw 'R291_GUARD_FUNCTION_COUNT_MISMATCH'} + $raw=Get-Content $src -Raw + $first=$raw.IndexOf('$validatedSensorSid=Assert-SensorPrivilegedBoundary',[StringComparison]::Ordinal) + $write=$raw.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) + if($first -lt 0 -or $write -le $first){throw 'R291_PREFLIGHT_MUST_PRECEDE_WRITES'} + $log.Add('PREFLIGHT_EARLIER_THAN_ALL_LOG_WRITES') + # Dot-source ONLY the two selected function ASTs, never the full privileged script. + . ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $validInstall=Join-Path $tempRoot ('tbme_setup_'+('a'*32)) + $validUninstall=Join-Path $tempRoot ('tbme_uninstall_'+('b'*32)) + if((Assert-SensorPrivilegedBoundary Install $validInstall $fixtureApp $user) -cne $sid -or + (Assert-SensorPrivilegedBoundary Uninstall $validUninstall $fixtureApp $user) -cne $sid){ + throw 'R291_VALID_USER_OR_PROFILE_REJECTED' + } + $log.Add('LEGITIMATE_INSTALL_AND_UNINSTALL_ACCEPTED') + Reject 'WRONG_APP_ROOT_REFUSED' Install $validInstall (Join-Path $env:WINDIR 'TaskbarMonitorEnhanced') $user + Reject 'OUTSIDE_USER_PROFILE_REFUSED' Install (Join-Path $env:WINDIR ('tbme_setup_'+('a'*32))) $fixtureApp $user + Reject 'DOWNLOADS_ROOT_WITH_VALID_NAME_REFUSED' Install (Join-Path ([Environment]::GetFolderPath('UserProfile')) ('Downloads\tbme_setup_'+('a'*32))) $fixtureApp $user + Reject 'NESTED_TEMP_WITH_VALID_NAME_REFUSED' Install (Join-Path (Join-Path $tempRoot 'nested') ('tbme_setup_'+('b'*32))) $fixtureApp $user + Reject 'WRONG_MODE_PAYLOAD_REFUSED' Uninstall $validInstall $fixtureApp $user + Reject 'MALFORMED_PAYLOAD_NAME_REFUSED' Install (Join-Path $tempRoot 'tbme_setup_bad') $fixtureApp $user + Reject 'SERVICE_SID_REFUSED' Install $validInstall $fixtureApp 'S-1-5-18' + Reject 'UNRESOLVABLE_WINDOWS_ACCOUNT_REFUSED' Install $validInstall $fixtureApp 'THIS_ACCOUNT_MUST_NOT_EXIST_R291' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R291_BOUNDARY_RESOURCE_MISSING'} + $reader=[IO.StreamReader]::new($stream) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $crlf=[string][char]13+[string][char]10 + $lf=[string][char]10 + if($embedded.Replace($crlf,$lf).TrimStart([char]0xFEFF).TrimEnd() -cne + $raw.Replace($crlf,$lf).TrimStart([char]0xFEFF).TrimEnd()){ + throw 'R291_EMBEDDED_HELPER_NOT_EXACT_SOURCE' + } + $log.Add('EMBEDDED_SETUP_HELPER_MATCHES_GUARDED_SOURCE') + $external=Join-Path $out 'outside' + [void][IO.Directory]::CreateDirectory($external) + $canary=Join-Path $external 'outside.txt' + [IO.File]::WriteAllText($canary,'R291_CANARY_NOT_TO_BE_CHANGED',[Text.UTF8Encoding]::new($false)) + $canarySHA=Hash $canary + $link=Join-Path $tempRoot ('tbme_setup_'+[guid]::NewGuid().ToString('N')) + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + if(-not ((Get-Item -LiteralPath $link -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R291_ACTUAL_WINDOWS_JUNCTION_REQUIRED' + } + Reject 'REAL_PAYLOAD_JUNCTION_REFUSED' Install $link $fixtureApp $user + [IO.Directory]::Delete($link) + $link='' + $missing=Join-Path $out 'missingTarget' + [void][IO.Directory]::CreateDirectory($missing) + $dangling=Join-Path $tempRoot ('tbme_setup_'+[guid]::NewGuid().ToString('N')) + New-Item -ItemType Junction -Path $dangling -Target $missing -ErrorAction Stop|Out-Null + [IO.Directory]::Delete($missing) + $missing='' + Reject 'DANGLING_PAYLOAD_JUNCTION_REFUSED' Install $dangling $fixtureApp $user + [IO.Directory]::Delete($dangling) + $dangling='' + if((Hash $live) -cne $mainBefore -or (Hash $canary) -cne $canarySHA){ + throw 'R291_INSTALLED_OR_EXTERNAL_CANARY_CHANGED' + } + $log.Add('MAIN_AND_CANARY_UNCHANGED') + $rec=[ordered]@{Schema=1;Revision='R291';Status='PASS_PRIVILEGED_HELPER_BOUNDARY_NATIVE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + ProtectedServicesOrUACInvoked=$false;RealWindowsJunctionUsed=$true + PositiveAndNegativePathChecksPassed=$true;DanglingJunctionRejected=$true + EmbeddedHelperSourceMatched=$true;InstalledMainUnchanged=$true + CheckCount=$log.Count;Checks=@($log);RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($rec|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R291_GUEST_'+$HostLabel+'_PRIVILEGE_BOUNDARY=PASS' +}finally{ + if($link){try{[IO.Directory]::Delete($link)}catch{}} + if($dangling){try{[IO.Directory]::Delete($dangling)}catch{}} + if($missing -and (Test-Path $missing)){try{[IO.Directory]::Delete($missing)}catch{}} + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R291';Status='FAIL_NOT_ACCEPTED';Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;MainPreserved=((Hash $live) -ceq $mainBefore) + OriginalOwnerComputerTouched=$false;RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 new file mode 100644 index 00000000..2aeb7795 --- /dev/null +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -0,0 +1,242 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# R293: only disposable GitHub-hosted Windows, never the owner's installation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R293_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function Hash([string]$p){ + if(!(Test-Path -LiteralPath $p -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$dir=Join-Path $env:RUNNER_TEMP ('R293_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $dir 'R293_INMEMORY_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$live=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R291_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R291_BOUNDARY_RECEIPT.json') +$payload=Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+[guid]::NewGuid().ToString('N')) +$rogue=Join-Path $payload 'TBME_Setup_Elevated_Helper.ps1' +$sentinel=Join-Path $dir 'ROGUE_EXECUTED' +$user=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) +$accepted=$false +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path $dir) -or (Test-Path $payload) -or (Test-Path $sentinel)){ + throw 'R293_PREEXISTING_GUEST_FIXTURE_REFUSED' +} +[void][IO.Directory]::CreateDirectory($dir) +$mainBefore=Hash $live +try{ + $identity=Get-Content $manifest -Raw |ConvertFrom-Json + $r291=Get-Content $prior -Raw |ConvertFrom-Json + if($identity.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $identity.SetupSHA256 -or + (Hash $live) -cne $identity.MainSHA256 -or + $r291.Status -cne 'PASS_PRIVILEGED_HELPER_BOUNDARY_NATIVE' -or + $r291.SourceCommit -cne $env:GITHUB_SHA -or $r291.Host -cne $HostLabel){ + throw 'R293_EXACT_CANDIDATE_OR_PRIOR_NATIVE_HOST_UNPINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $m=$type.GetMethod('BuildElevatedHelperCommand',[Reflection.BindingFlags]'NonPublic,Static') + if(!$m){throw 'R293_SETUP_HAS_NO_INMEMORY_ELEVATION_BUILDER'} + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string]$payload + # Known bad AppRoot: real trusted Helper must refuse before any filesystem mutation. + $args4[2]=[string](Join-Path $env:WINDIR 'R293_Refuse_Outside_User_AppRoot') + $args4[3]=[string]$user + $cmd=[string]$m.Invoke($null,$args4) + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + if($cmd.Contains(' -File ') -or $cmd.Contains('ExecutionPolicy Bypass')){ + throw 'R293_ELEVATION_COMMAND_USES_UNTRUSTED_FILE_PATH' + } + $encodedTransport=$cmd.StartsWith($prefix,[StringComparison]::Ordinal) + if($encodedTransport){ + $encoded=$cmd.Substring($prefix.Length) + if($encoded.Length -lt 1000 -or $encoded.Length -gt 32000){ + throw 'R293_ENCODED_COMMAND_LENGTH_OUT_OF_BOUNDS' + } + $script=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + }else{ + if(-not $cmd.StartsWith($inlinePrefix,[StringComparison]::Ordinal) -or + $cmd.Length -gt 30000){throw 'R293_UNSAFE_OR_LONG_INLINE_COMMAND'} + $encoded=$cmd.Substring($inlinePrefix.Length) + $script=$encoded + } + if(-not $script.Contains('[ScriptBlock]::Create($code)') -or + $script.Contains(' -File ')){ + throw 'R293_DECODED_COMMAND_DOES_NOT_RUN_EMBEDDED_SOURCE' + } + $checks.Add('NO_USER_WRITABLE_POWERSHELL_FILE_ENTRYPOINT') + $re=[regex]::Match($script,'(?m)^\$blob=\[Convert\]::FromBase64String\(''([a-zA-Z0-9+/=]+)''\)') + if(!$re.Success){throw 'R293_MISSING_TRUSTED_COMPRESSED_SOURCE'} + $memory=[IO.MemoryStream]::new([Convert]::FromBase64String($re.Groups[1].Value)) + $zip=[IO.Compression.GZipStream]::new($memory,[IO.Compression.CompressionMode]::Decompress) + $reader=[IO.StreamReader]::new($zip,[Text.Encoding]::UTF8) + try{$code=$reader.ReadToEnd()}finally{$reader.Dispose();$zip.Dispose();$memory.Dispose()} + $resource=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$resource){throw 'R293_MISSING_SETUP_RESOURCE'} + $textReader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) + try{$source=$textReader.ReadToEnd()}finally{$textReader.Dispose()} + # R295 transports a comment/indentation-compacted representation of the + # exact source PE resource, not an independently writable source file. + $compactor=$type.GetMethod('CompactTrustedHelperSource',[Reflection.BindingFlags]'NonPublic,Static') + if(!$compactor){throw 'R295_TRUSTED_SOURCE_COMPACTOR_MISSING'} + $expected=[string]$compactor.Invoke($null,([object[]]@([string]$source))) + if($code -cne $expected){ + throw 'R295_PACKED_SOURCE_DIFFERS_FROM_COMPACTED_SETUP_RESOURCE' + } + $checks.Add('DECOMPRESSED_RESOURCE_EXACT_TO_COMPACTED_SETUP') + if(@($script|Select-String -Pattern ([regex]::Escape($args4[2]))).Count){ + throw 'R293_UNTRUSTED_PATH_EMBEDDED_AS_RAW_SCRIPT_LITERAL' + } + $checks.Add('ALL_USER_PARAMETERS_ENCODED_AS_DATA') + [void][IO.Directory]::CreateDirectory($payload) + # This file was the previous vulnerable UAC execution entrypoint. + # It is deliberately attacker-like, but owned only by this temporary fixture. + $poison=[string]'Write-Output "R293_ROGUE_SCRIPT_EXECUTED"' + [IO.File]::WriteAllText($rogue,$poison,[Text.UTF8Encoding]::new($false)) + $rogueBefore=Hash $rogue + # No UAC: execute only invalid-Root probe on the disposable runner. + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName='powershell.exe' + $psi.Arguments=$cmd + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw 'R293_BOUNDED_NON_ELEVATED_PROCESS_NOT_CREATED'} + try{ + # Drain both redirected pipes concurrently so even verbose error output + # cannot block the child before WaitForExit sees the bounded refusal. + $outTask=$proc.StandardOutput.ReadToEndAsync() + $errTask=$proc.StandardError.ReadToEndAsync() + if(-not $proc.WaitForExit(20000)){ + try{$proc.Kill()}catch{} + throw 'R293_INMEMORY_HELPER_TEST_TIMEOUT' + } + $out=$outTask.GetAwaiter().GetResult() + $err=$errTask.GetAwaiter().GetResult() + $exitCode=$proc.ExitCode + }finally{$proc.Dispose()} + if($exitCode -eq 0 -or + ($out+$err) -notmatch 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE'){ + throw ('R293_EXPECTED_PREWRITE_REFUSAL_NOT_OBSERVED_EXIT_'+$exitCode) + } + if($out -match 'R293_ROGUE_SCRIPT_EXECUTED' -or + $err -match 'R293_ROGUE_SCRIPT_EXECUTED' -or + (Hash $rogue) -cne $rogueBefore -or + (Test-Path $sentinel)){ + throw 'R293_USER_WRITABLE_ROGUE_SCRIPT_EXECUTED_OR_CHANGED' + } + $checks.Add('REAL_POWERSHELL_51_INLINE_PREWRITE_REFUSAL') + $checks.Add('ROGUE_WRITABLE_HELPER_IGNORED') + # R293B: an adversarial account argument contains literal PowerShell tokens. + # It must be data in the inline command, never executed as PowerShell code. + $evilUser=$user+"'; [IO.File]::WriteAllText('"+$sentinel+"','R293_INJECTION'); #" + $args4[3]=[string]$evilUser + $evilCmd=[string]$m.Invoke($null,$args4) + if($evilCmd.StartsWith($prefix,[StringComparison]::Ordinal)){ + $evilBootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($evilCmd.Substring($prefix.Length))) + }else{ + # R305-F01: the added prewrite SID guard grows the trusted helper. + # An adversarial longer user input now legitimately selects R304's + # bounded inline -Command instead of UTF16 -EncodedCommand. + # The exact trusted PE blob, no writable -File and Base64-only user + # arguments are mandatory in EITHER supported transport. + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + if(-not $evilCmd.StartsWith($inlinePrefix,[StringComparison]::Ordinal) -or + $evilCmd.Length -gt 30000 -or + $evilCmd.Contains(' -File ') -or + $evilCmd.Contains('ExecutionPolicy Bypass')){ + throw 'R293_INJECTED_COMMAND_UNSAFE_OR_UNBOUNDED' + } + $evilBootstrap=$evilCmd.Substring($inlinePrefix.Length) + if(-not $evilBootstrap.Contains($re.Groups[1].Value) -or + -not $evilBootstrap.Contains('[ScriptBlock]::Create($code)')){ + throw 'R293_LONG_FALLBACK_SOURCE_NOT_PINNED_TO_SAME_PE_RESOURCE' + } + } + if($evilBootstrap.Contains($evilUser) -or $evilBootstrap.Contains($sentinel)){ + throw 'R293_CALLER_ARGUMENT_WAS_INTERPOLATED_AS_SCRIPT_SOURCE' + } + $checks.Add('QUOTED_HOSTILE_USER_INPUT_ENCODED_AS_DATA') + $evilProcess=[Diagnostics.ProcessStartInfo]::new() + $evilProcess.FileName='powershell.exe' + $evilProcess.Arguments=$evilCmd + $evilProcess.UseShellExecute=$false + $evilProcess.CreateNoWindow=$true + $evilProcess.RedirectStandardOutput=$true + $evilProcess.RedirectStandardError=$true + $evil=[Diagnostics.Process]::Start($evilProcess) + if(!$evil){throw 'R293_HOSTILE_INJECTION_PROBE_PROCESS_MISSING'} + try{ + $evilStdout=$evil.StandardOutput.ReadToEndAsync() + $evilStderr=$evil.StandardError.ReadToEndAsync() + if(-not $evil.WaitForExit(20000)){ + try{$evil.Kill()}catch{} + throw 'R293_INJECTED_COMMAND_PROCESS_TIMEOUT' + } + $evilOut=$evilStdout.GetAwaiter().GetResult() + $evilErr=$evilStderr.GetAwaiter().GetResult() + $evilExit=$evil.ExitCode + }finally{$evil.Dispose()} + if($evilExit -eq 0 -or + ($evilOut+$evilErr) -notmatch 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' -or + (Test-Path -LiteralPath $sentinel) -or + (Hash $rogue) -cne $rogueBefore){ + throw 'R293_COMMAND_INJECTION_NEGATIVE_PROBE_NOT_FAIL_CLOSED' + } + $checks.Add('COMMAND_LINE_INJECTION_SENTINEL_NOT_EXECUTED') + $checks.Add('NO_UAC_PROTECTED_SERVICE_MUTATION') + if((Hash $live) -cne $mainBefore){ + throw 'R293_GUEST_INSTALLED_MAIN_CHANGED' + } + $checks.Add('INSTALLED_MAIN_AND_ROGUE_FILE_PRESERVED') + $receipt=[ordered]@{ + Schema=1;Revision='R293' + Status='PASS_NO_TEMP_SCRIPT_ELEVATION_INLINE_TRUSTED_SOURCE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + UACOrProtectedServiceCalled=$false + ExactTrustedHelperSource=$true + MaliciousTempScriptIgnored=$true + RealNonElevatedInMemoryPowerShellCalled=$true + InvalidAppRootRejectedBeforeWrite=$true + InstalledMainUnchanged=$true + BoundedInMemoryCommandLength=$cmd.Length + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($receipt|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R293_NATIVE_'+$HostLabel+'_TRUSTED_ELEVATION_CODE=PASS' +}finally{ + if(Test-Path -LiteralPath $payload){ + try{[IO.Directory]::Delete($payload,$true)}catch{} + } + if(!$accepted){ + $fail=[ordered]@{ + Schema=1;Revision='R293';Status='FAIL_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerUntouched=$true + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R294-PayloadProvenance-Native.ps1 b/build/Test-R294-PayloadProvenance-Native.ps1 new file mode 100644 index 00000000..72f7c2c6 --- /dev/null +++ b/build/Test-R294-PayloadProvenance-Native.ps1 @@ -0,0 +1,135 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R294_ONLY_GITHUB_DISPOSABLE_WINDOWS' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R294_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $root 'R294_PROVENANCE_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R293_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R293_INMEMORY_RECEIPT.json') +$accepted=$false +$check=[Collections.Generic.List[string]]::new() +if(Test-Path $root){throw 'R294_GUEST_FIXTURE_ALREADY_PRESENT'} +[void][IO.Directory]::CreateDirectory($root) +$mainBefore=Hash $main +try{ + $info=Get-Content $manifest -Raw|ConvertFrom-Json + $receipt=Get-Content $prior -Raw|ConvertFrom-Json + if($info.Commit -cne $env:GITHUB_SHA -or $info.SetupSHA256 -cne (Hash $setup) -or + $info.MainSHA256 -cne $mainBefore -or + $receipt.SourceCommit -cne $env:GITHUB_SHA -or $receipt.Host -cne $HostLabel -or + $receipt.Status -cne 'PASS_NO_TEMP_SCRIPT_ELEVATION_INLINE_TRUSTED_SOURCE'){ + throw 'R294_EXACT_GUEST_BASELINE_OR_SOURCE_NOT_PINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $m=$type.GetMethod('BuildElevatedHelperCommand',[Reflection.BindingFlags]'NonPublic,Static') + if(!$m){throw 'R294_EXPECTED_COMMAND_BUILDER_MISSING'} + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string](Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+('a'*32))) + $args4[2]=[string]$app + $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) + $command=[string]$m.Invoke($null,$args4) + $encodedPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + $encoded=$command.StartsWith($encodedPrefix,[StringComparison]::Ordinal) + $inline=$command.StartsWith($inlinePrefix,[StringComparison]::Ordinal) + if((-not $encoded -and -not $inline) -or $command.Contains(' -File ') -or + $command.Contains('ExecutionPolicy Bypass') -or + ($encoded -and $command.Length -gt 32080) -or + ($inline -and $command.Length -gt 30000)){ + throw 'SENSOR_TRUSTED_BOOTSTRAP_TRANSPORT_UNSAFE' + } + $bootstrap=if($encoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($command.Substring($encodedPrefix.Length))) + }else{$command.Substring($inlinePrefix.Length)} + $match=[regex]::Match($bootstrap,"-ExpectedBrokerSHA256 '([A-F0-9]{64})' -ExpectedSupervisorSHA256 '([A-F0-9]{64})'") + if(!$match.Success){throw 'R294_TRUSTED_EMBEDDED_HASHES_NOT_TRANSPORTED'} + $check.Add('RESOURCE_IDENTITY_PINNED_INSIDE_TRUSTED_IN_MEMORY_BOOTSTRAP') + $helperPath=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path + $tokens=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($helperPath,[ref]$tokens,[ref]$errors) + if(@($errors).Count){throw 'R294_HELPER_WINDOWS_POWERSHELL51_PARSE_FAILED'} + $functions=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Get-VerifiedSensorPayloadBytes') + },$true)) + if($functions.Count -ne 2){throw 'R294_EXPECTED_TWO_PURE_FUNCTIONS'} + . ([scriptblock]::Create(($functions|ForEach-Object{$_.Extent.Text}) -join [Environment]::NewLine)) + foreach($kind in @( + @{Name='Broker';Resource='Payload.TaskbarMonitorSensorBroker.exe';Expected=$match.Groups[1].Value}, + @{Name='Supervisor';Resource='Payload.TaskbarMonitorSensorSupervisor.exe';Expected=$match.Groups[2].Value} + )){ + $path=Join-Path $root ('TBME_R294_'+$kind.Name+'.exe') + $stream=$assembly.GetManifestResourceStream($kind.Resource) + if(!$stream){throw 'R294_EMBEDDED_EXECUTABLE_RESOURCE_MISSING'} + $out=[IO.FileStream]::new($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($out)}finally{$out.Dispose();$stream.Dispose()} + if((Hash $path) -cne $kind.Expected){throw 'R294_EMBEDDED_RESOURCE_HASH_NOT_BOUND'} + $verified=[byte[]](Get-VerifiedSensorPayloadBytes $path $kind.Expected 16777216) + $verifiedSHA=[BitConverter]::ToString(([Security.Cryptography.SHA256]::Create()).ComputeHash($verified)).Replace('-','') + if($verifiedSHA -cne $kind.Expected){throw 'R294_VERIFIED_PAYLOAD_BYTES_ARE_DIFFERENT'} + $check.Add($kind.Name.ToUpperInvariant()+'_SAME_VERIFIED_BYTES_AS_EMBEDDED') + $installed=Join-Path $root ('TRUSTED_COPY_'+$kind.Name+'.exe') + [IO.File]::WriteAllBytes($installed,$verified) + if((Hash $installed) -cne $kind.Expected){throw 'R294_PROVENANCE_BUFFER_COULD_NOT_BE_INSTALLED'} + $check.Add($kind.Name.ToUpperInvariant()+'_IN_MEMORY_COPY_IDENTITY') + $rogue=[IO.File]::ReadAllBytes($path) + $rogue[0]=$rogue[0] -bxor 0x01 + [IO.File]::WriteAllBytes($path,$rogue) + $refused=$false + try{$null=Get-VerifiedSensorPayloadBytes $path $kind.Expected 16777216} + catch{if($_.Exception.Message -match 'SENSOR_PAYLOAD_EMBEDDED_SHA_MISMATCH'){$refused=$true}else{throw}} + if(!$refused){throw 'R294_TAMPERED_SOURCE_WAS_ACCEPTED'} + $check.Add($kind.Name.ToUpperInvariant()+'_TAMPERED_TEMP_BYTES_REJECTED') + $locked=[IO.FileStream]::new($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + $blocked=$false + try{$null=Get-VerifiedSensorPayloadBytes $path $kind.Expected 16777216} + catch [IO.IOException]{$blocked=$true} + if(!$blocked){throw 'R294_EXCLUSIVE_SOURCE_LOCK_FAILED'} + }finally{$locked.Dispose()} + $check.Add($kind.Name.ToUpperInvariant()+'_CONCURRENT_ACCESS_BLOCKED') + } + if((Hash $main) -cne $mainBefore){throw 'R294_PROTECTED_MAIN_MUTATED'} + $check.Add('EXTERNAL_GUEST_INSTALLED_MAIN_UNCHANGED') + $record=[ordered]@{ + Schema=1;Revision='R294';Status='PASS_SHA_PINNED_EXCLUSIVE_SINGLE_READ_SENSOR_BINARIES' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UACOrServiceMutation=$false + ExpectedBrokerSHA256=$match.Groups[1].Value + ExpectedSupervisorSHA256=$match.Groups[2].Value + BothTamperedInputsRejected=$true;ExclusiveLockProved=$true + VerifiedBuffersInstalledToSandboxOnly=$true;InstalledMainUnchanged=$true + Checks=@($check);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($record|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R294_GUEST_'+$HostLabel+'_EXCLUSIVE_EMBEDDED_BINARY_PROVENANCE_PASS' +}finally{ + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R294';Status='FAILED_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerUntouched=$true;RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R295-TrustedBackendZip-Native.ps1 b/build/Test-R295-TrustedBackendZip-Native.ps1 new file mode 100644 index 00000000..b34c1637 --- /dev/null +++ b/build/Test-R295-TrustedBackendZip-Native.ps1 @@ -0,0 +1,191 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R295_ONLY_DISPOSABLE_GITHUB_WINDOWS_VM' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +function ExpectRefusal([string]$Name,[string]$File,[string]$SHA,[string]$ExpectedMessage){ + $blocked=$false + try{$null=Get-VerifiedSensorBackendEntries $File $SHA} + catch{ + if($_.Exception.Message -match [regex]::Escape($ExpectedMessage)){$blocked=$true}else{ + throw ('R295_UNEXPECTED_REFUSAL_'+$Name+': '+$_.Exception.Message) + } + } + if(!$blocked){throw ('R295_UNSAFE_ARCHIVE_ACCEPTED_'+$Name)} + $script:checks.Add($Name) +} +function MakeSyntheticZip([string]$Destination,[string]$Variant){ + $buffer=[IO.MemoryStream]::new() + $zip=[IO.Compression.ZipArchive]::new($buffer,[IO.Compression.ZipArchiveMode]::Create,$true) + try{ + for($i=0;$i -lt 43;$i++){ + $name=('fixtures/entry'+$i.ToString('D2')+'.bin') + if($Variant -eq 'TRAVERSAL' -and $i -eq 0){$name='../outside.dll'} + if($Variant -eq 'DUPLICATE' -and $i -eq 0){$name='fixtures/COLLIDE.dll'} + if($Variant -eq 'DUPLICATE' -and $i -eq 1){$name='FIXTURES/collide.DLL'} + $entry=$zip.CreateEntry($name) + if($Variant -eq 'LINK' -and $i -eq 0){$entry.ExternalAttributes=[int]-1610612736} + $stream=$entry.Open() + try{$data=[byte[]]@(0x41,[byte]$i,0x42);$stream.Write($data,0,$data.Length)} + finally{$stream.Dispose()} + } + }finally{$zip.Dispose()} + [IO.File]::WriteAllBytes($Destination,$buffer.ToArray()) + $buffer.Dispose() +} +$root=Join-Path $env:RUNNER_TEMP ('R295_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receiptFile=Join-Path $root 'R295_ZIP_PROVENANCE_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R294_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R294_PROVENANCE_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$success=$false +if(Test-Path $root){throw 'R295_PREEXISTING_FIXTURE_REFUSED'} +[void][IO.Directory]::CreateDirectory($root) +$beforeMain=Hash $main +try{ + $id=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json + $prev=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json + if($id.Commit -cne $env:GITHUB_SHA -or (Hash $setup) -cne $id.SetupSHA256 -or + (Hash $main) -cne $id.MainSHA256 -or + $prev.SourceCommit -cne $env:GITHUB_SHA -or $prev.Host -cne $HostLabel -or + $prev.Status -cne 'PASS_SHA_PINNED_EXCLUSIVE_SINGLE_READ_SENSOR_BINARIES'){ + throw 'R295_EXACT_SOURCE_AND_PRIOR_RECEIPT_NOT_VERIFIED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $m=$type.GetMethod('BuildElevatedHelperCommand',[Reflection.BindingFlags]'NonPublic,Static') + if(!$m){throw 'R295_BUILD_INLINE_COMMAND_METHOD_MISSING'} + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string](Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+('a'*32))) + $args4[2]=[string]$app + $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) + $command=[string]$m.Invoke($null,$args4) + $encodedPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + $encoded=$command.StartsWith($encodedPrefix,[StringComparison]::Ordinal) + $inline=$command.StartsWith($inlinePrefix,[StringComparison]::Ordinal) + if((-not $encoded -and -not $inline) -or $command.Contains(' -File ') -or + $command.Contains('ExecutionPolicy Bypass') -or + ($encoded -and $command.Length -gt 32080) -or + ($inline -and $command.Length -gt 30000)){ + throw 'SENSOR_TRUSTED_BOOTSTRAP_TRANSPORT_UNSAFE' + } + $bootstrap=if($encoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($command.Substring($encodedPrefix.Length))) + }else{$command.Substring($inlinePrefix.Length)} + $match=[regex]::Match($bootstrap,"-ExpectedBackendZipSHA256 '([A-F0-9]{64})'") + if(!$match.Success){throw 'R295_EMBEDDED_ZIP_SHA_NOT_TRANSPORTED'} + $expectedHash=$match.Groups[1].Value + $checks.Add('PE_EMBEDDED_ZIP_SHA_TRANSPORTED') + $compactor=$type.GetMethod('CompactTrustedHelperSource',[Reflection.BindingFlags]'NonPublic,Static') + if(!$compactor){throw 'R295_SOURCE_COMPACTOR_NOT_FOUND'} + $helper=Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1' + $helper=[IO.Path]::GetFullPath($helper) + $tokens=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) + if(@($errors).Count){throw 'R295_PS51_HELPER_SYNTAX_INVALID'} + $selected=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Get-VerifiedSensorPayloadBytes','Get-VerifiedSensorBackendEntries') + },$true)) + if($selected.Count -ne 3){throw 'R295_THREE_SECURITY_FUNCTIONS_NOT_PRESENT'} + . ([scriptblock]::Create(($selected|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $raw=Get-Content -LiteralPath $helper -Raw + $zipResource=$assembly.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$zipResource){throw 'R295_SETUP_MISSING_BUNDLED_ZIP_RESOURCE'} + $zipPath=Join-Path $root 'LibreHardwareMonitor.zip' + $writer=[IO.FileStream]::new($zipPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$zipResource.CopyTo($writer)}finally{$writer.Dispose();$zipResource.Dispose()} + if((Hash $zipPath) -cne $expectedHash){throw 'R295_STAGED_ZIP_NOT_PE_EMBEDDED'} + $checks.Add('STAGED_ZIP_MATCHES_PE_PINNED_SHA') + $items=Get-VerifiedSensorBackendEntries $zipPath $expectedHash + if(@($items).Count -ne 43){throw 'R295_EXPECTED_43_FILES_MISSING'} + $count=0 + $bytesTotal=0 + $dir=Join-Path $root 'private-sandbox' + [void][IO.Directory]::CreateDirectory($dir) + foreach($item in $items){ + $destination=Join-Path $dir ($item.Rel.Replace('/','\')) + $parent=[IO.Path]::GetDirectoryName($destination) + if($parent){[void][IO.Directory]::CreateDirectory($parent)} + [IO.File]::WriteAllBytes($destination,[byte[]]$item.Bytes) + $sha=[Security.Cryptography.SHA256]::Create() + try{$expectedFileSHA=[BitConverter]::ToString($sha.ComputeHash([byte[]]$item.Bytes)).Replace('-','')} + finally{$sha.Dispose()} + if((Hash $destination) -cne $expectedFileSHA){throw 'R295_VERIFIED_BUFFER_COPY_DIFFERS'} + $count++;$bytesTotal+=$item.Bytes.Length + } + if($count -ne 43 -or $bytesTotal -ne 12203773 -or + @(Get-ChildItem -LiteralPath $dir -Recurse -File).Count -ne 43){ + throw 'R295_PROTECTED_DEPENDENCIES_NOT_EXACT_43' + } + $checks.Add('ALL_43_ARCHIVE_ENTRIES_ARE_VERIFIED_BUFFERS') + $checks.Add('COPIED_ONLY_VERIFIED_BUFFERS_TO_ISOLATED_SANDBOX') + $tampered=[IO.File]::ReadAllBytes($zipPath) + $tampered[10]=$tampered[10] -bxor 0x01 + [IO.File]::WriteAllBytes($zipPath,$tampered) + $blocked=$false + try{$null=Get-VerifiedSensorBackendEntries $zipPath $expectedHash} + catch{if($_.Exception.Message -match 'SENSOR_PAYLOAD_EMBEDDED_SHA_MISMATCH'){$blocked=$true}else{throw}} + if(!$blocked){throw 'R295_TAMPERED_ARCHIVE_NOT_REJECTED'} + $checks.Add('TAMPERED_ARCHIVE_REJECTED_BEFORE_ANY_EXTRACTION') + $exclusive=[IO.FileStream]::new($zipPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + $blocked=$false + try{$null=Get-VerifiedSensorBackendEntries $zipPath $expectedHash} + catch [IO.IOException]{$blocked=$true} + if(!$blocked){throw 'R295_EXCLUSIVE_ZIP_HANDLE_NOT_PROTECTED'} + }finally{$exclusive.Dispose()} + $checks.Add('EXCLUSIVE_ZIP_FILE_HANDLE_BLOCKS_SECOND_READER') + foreach($mode in @('TRAVERSAL','DUPLICATE','LINK')){ + $filename=Join-Path $root ($mode+'.zip') + MakeSyntheticZip $filename $mode + $expected=switch($mode){ + 'TRAVERSAL'{'SENSOR_BACKEND_ZIP_UNSAFE_ENTRY'} + 'DUPLICATE'{'SENSOR_BACKEND_ZIP_DUPLICATE_ENTRY'} + 'LINK'{'SENSOR_BACKEND_ZIP_LINK_REFUSED'} + } + ExpectRefusal ('ADVERSARIAL_ZIP_'+$mode+'_REJECTED') $filename (Hash $filename) $expected + } + $checks.Add('ZIP_PATHS_HAVE_NO_ESCAPE_SYMLINK_OR_COLLISION') + $checks.Add('NO_HOST_UAC_OR_PROTECTED_SENSOR_SERVICE_ACTION') + if((Hash $main) -cne $beforeMain){throw 'R295_RUNNING_MAIN_CHANGED'} + $checks.Add('INSTALLED_MAIN_UNCHANGED') + $proof=[ordered]@{ + Schema=1;Revision='R295';Status='PASS_PE_PINNED_ZIP_BYTE_ONLY_BACKEND_PROVENANCE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ElevatedUACOrProtectedServiceUsed=$false + EmbeddedArchiveSHA256=$expectedHash;EntryCount=$count;UncompressedBytes=$bytesTotal + TamperedArchiveRefused=$true;TraversalAndDuplicateAndLinkRefused=$true + ExclusiveSourceLockVerified=$true;VerifiedBytesOnlyCopiedToSandbox=$true + InstalledMainUnchanged=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receiptFile,($proof|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $success=$true + 'R295_NATIVE_'+$HostLabel+'_PINNED_BUNDLED_ZIP_PASS' +}finally{ + if(!$success){ + $fail=[ordered]@{Schema=1;Revision='R295';Status='FAILED_NOT_ACCEPTED';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerHostUntouched=$true} + [IO.File]::WriteAllText($receiptFile,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R296-RollbackMemory-Native.ps1 b/build/Test-R296-RollbackMemory-Native.ps1 new file mode 100644 index 00000000..fc6bdcf9 --- /dev/null +++ b/build/Test-R296-RollbackMemory-Native.ps1 @@ -0,0 +1,143 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R296_ONLY_GITHUB_DISPOSABLE_WINDOWS' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +function ExpectRefusal([string]$Name,[scriptblock]$Body,[string]$Match){ + $refused=$false + try{& $Body} + catch{ + if($_.Exception.Message -match $Match){$refused=$true}else{throw ('R296_UNEXPECTED_'+$Name+': '+$_.Exception.Message)} + } + if(!$refused){throw ('R296_FAIL_OPEN_'+$Name)} + $script:checks.Add($Name) +} +$root=Join-Path $env:RUNNER_TEMP ('R296_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R296_MEMORY_ROLLBACK_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$liveMain=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R295_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R295_ZIP_PROVENANCE_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$accepted=$false +if(Test-Path $root){throw 'R296_PREEXISTING_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($root) +$mainBefore=Hash $liveMain +try{ + $identity=Get-Content $manifest -Raw|ConvertFrom-Json + $previous=Get-Content $prior -Raw|ConvertFrom-Json + if($identity.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $identity.SetupSHA256 -or + (Hash $liveMain) -cne $identity.MainSHA256 -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_PE_PINNED_ZIP_BYTE_ONLY_BACKEND_PROVENANCE'){ + throw 'R296_EXACT_HOST_SOURCE_OR_R295_NOT_VERIFIED' + } + $helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path + $raw=Get-Content $helper -Raw + if($raw.Contains('$RollbackRoot=Join-Path $PayloadDir') -or + $raw.Contains('Copy-Item -LiteralPath $RollbackBrokerRoot') -or + -not $raw.Contains('$script:RollbackFiles=Get-SensorRollbackFiles $BrokerRoot') -or + -not $raw.Contains('Restore-SensorRollbackFiles $BrokerRoot $RollbackFiles') -or + -not $raw.Contains('$script:RollbackTaskXmlText=[string](Export-ScheduledTask')){ + throw 'R296_USER_WRITABLE_ROLLBACK_STILL_ACTIVE' + } + $checks.Add('NO_TEMP_SNAPSHOT_OR_TASK_XML_PATH_IN_PRODUCTION_HELPER') + $t=$null;$e=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$e) + if(@($e).Count){throw 'R296_WINDOWS_POWER_SHELL_51_PARSE_FAILED'} + $defs=@($ast.FindAll({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Get-SensorRollbackFiles','Restore-SensorRollbackFiles','Clear-SensorRollbackBuffer')},$true)) + if($defs.Count -ne 4){throw 'R296_EXPECTED_FOUR_PURE_SECURITY_FUNCTIONS'} + . ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $old=Join-Path $root 'old-broker' + $target=Join-Path $root 'restored-broker' + $fakeTemp=Join-Path $root 'attacker-TEMP-snapshot' + $external=Join-Path $root 'external-canary' + foreach($p in @($old,$fakeTemp,$external)){[void][IO.Directory]::CreateDirectory($p)} + $broker=Join-Path $old 'TaskbarMonitorSensorBroker.exe' + $library=Join-Path $old 'LibreHardwareMonitorLib.dll' + $nested=Join-Path $old 'lang' + [void][IO.Directory]::CreateDirectory($nested) + $subfile=Join-Path $nested 'safe.dll' + [IO.File]::WriteAllBytes($broker,[byte[]](1,2,3,4,5,6,7,8,9)) + [IO.File]::WriteAllBytes($library,[byte[]](10,9,8,7,6,5,4,3,2,1)) + [IO.File]::WriteAllBytes($subfile,[byte[]](42,77,83)) + $beforeBroker=Hash $broker + $beforeLibrary=Hash $library + $beforeSub=Hash $subfile + $snapshot=Get-SensorRollbackFiles $old + if(@($snapshot).Count -ne 3){throw 'R296_MISSING_CAPTURED_SNAPSHOT_FILES'} + $checks.Add('THREE_PROGRAMFILES_LIKE_FILES_CAPTURED_ONLY_IN_MEMORY') + [IO.File]::WriteAllBytes($broker,[byte[]](200,201,202)) + [IO.File]::WriteAllBytes((Join-Path $fakeTemp 'TaskbarMonitorSensorBroker.exe'),[byte[]](255,255)) + Restore-SensorRollbackFiles $target $snapshot + if((Hash (Join-Path $target 'TaskbarMonitorSensorBroker.exe')) -cne $beforeBroker -or + (Hash (Join-Path $target 'LibreHardwareMonitorLib.dll')) -cne $beforeLibrary -or + (Hash (Join-Path $target 'lang\safe.dll')) -cne $beforeSub){ + throw 'R296_REPLAY_BYTES_DIFFER_FROM_PRIVATE_SNAPSHOT' + } + $checks.Add('RESTORE_EXACT_PRIOR_CONTENT_FROM_PRIVATE_MEMORY') + $checks.Add('MUTABLE_TEMP_FORGED_SNAPSHOT_IGNORED') + $script:RollbackFiles=$snapshot + $script:RollbackCaptured=$true + $script:RollbackTaskXmlText='R296_IN_MEMORY_ONLY' + Clear-SensorRollbackBuffer + if(@($script:RollbackFiles).Count -ne 0 -or $script:RollbackCaptured -or $null -ne $script:RollbackTaskXmlText){ + throw 'R296_SNAPSHOT_MEMORY_NOT_WIPED' + } + $checks.Add('ROLLBACK_DATA_AND_XML_MEMORY_CLEARED') + ExpectRefusal 'PARENT_TRAVERSAL_REJECTED' {Restore-SensorRollbackFiles $target @([pscustomobject]@{Rel='..\outside.dll';Data=[byte[]](77)})} 'ROLLBACK_RESTORE_BAD_NAME' + $canary=Join-Path $external 'outside.txt' + [IO.File]::WriteAllText($canary,'R296_UNMODIFIED') + $canarySHA=Hash $canary + $link=Join-Path $old 'junction' + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + try{ + ExpectRefusal 'JUNCTIONED_SNAPSHOT_INPUT_REJECTED' {Get-SensorRollbackFiles $old|Out-Null} 'SENSOR_BOUNDARY_REPARSE_REFUSED' + }finally{[IO.Directory]::Delete($link)} + $escape=Join-Path $target 'escape' + New-Item -ItemType Junction -Path $escape -Target $external -ErrorAction Stop|Out-Null + try{ + ExpectRefusal 'JUNCTIONED_RESTORE_DESTINATION_REJECTED' {Restore-SensorRollbackFiles $target @([pscustomobject]@{Rel='escape\outside.txt';Data=[byte[]](1,2,3)})} 'SENSOR_BOUNDARY_REPARSE_REFUSED' + }finally{[IO.Directory]::Delete($escape)} + if((Hash $canary) -cne $canarySHA){throw 'R296_EXTERNAL_CANARY_MUTATED'} + $checks.Add('EXTERNAL_CANARY_PRESERVED') + $lock=[IO.FileStream]::new($library,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ExpectRefusal 'CONCURRENT_SOURCE_READER_REFUSED' {Get-SensorRollbackFiles $old|Out-Null} 'sharing violation|being used|used by another process'}finally{$lock.Dispose()} + if((Hash $liveMain) -cne $mainBefore){throw 'R296_INSTALLED_GUEST_MAIN_CHANGED'} + $checks.Add('INSTALLED_MAIN_UNCHANGED') + if(Test-Path (Join-Path $root '_sensor_rollback')){throw 'R296_SNAPSHOT_DID_WRITE_DISK'} + $checks.Add('NO_DISK_ROLLBACK_CREATED') + $rec=[ordered]@{ + Schema=1;Revision='R296';Status='PASS_MEMORY_ONLY_ROLLBACK_NO_USER_TEMP_TRUST' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + UACOrProtectedServiceCalled=$false;PrivateSnapshotFiles=3 + ForgedUserTempIgnored=$true;TwoJunctionDirectionsRejected=$true + CorruptedOriginalRestoredFromMemory=$true;NoRollbackDiskFiles=$true + InstalledMainUnchanged=$true;Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($rec|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R296_GUEST_'+$HostLabel+'_IN_MEMORY_ROLLBACK=PASS' +}finally{ + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R296';Status='FAILED_NOT_ACCEPTED';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerHostUntouched=$true} + [IO.File]::WriteAllText($receipt,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R297-NoPawnAutoElevation-Native.ps1 b/build/Test-R297-NoPawnAutoElevation-Native.ps1 new file mode 100644 index 00000000..3c35a9ff --- /dev/null +++ b/build/Test-R297-NoPawnAutoElevation-Native.ps1 @@ -0,0 +1,155 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R297_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +function RunGuard([string]$Guard,[string]$Version,[string]$Fake){ + $base64Fake=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Fake)) + $script=@( + '$ErrorActionPreference="Stop"', + 'function Get-PawnIOVersion { return "'+$Version+'" }', + 'function Write-Log {param([string]$Text)}', + 'function Write-Result { Write-Output ("R297_RESULT_"+[string]$args[0]) }', + '$ActiveArchitecture=""', + '$PawnPayload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'+$base64Fake+'"))', + $Guard, + 'Write-Output "R297_SAFE_COMPATIBLE_REACHED"' + ) -join [string][char]10 + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script)) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName='powershell.exe' + $psi.Arguments='-NoProfile -NonInteractive -EncodedCommand '+$encoded + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw 'R297_NEGATIVE_GUARD_PROCESS_NOT_STARTED'} + try{ + $outTask=$proc.StandardOutput.ReadToEndAsync() + $errTask=$proc.StandardError.ReadToEndAsync() + if(!$proc.WaitForExit(15000)){ + try{$proc.Kill()}catch{} + throw 'R297_NATIVE_GUARD_PROCESS_TIMEOUT' + } + return [pscustomobject]@{ + Exit=$proc.ExitCode + Out=$outTask.GetAwaiter().GetResult() + Err=$errTask.GetAwaiter().GetResult() + } + }finally{$proc.Dispose()} +} +$root=Join-Path $env:RUNNER_TEMP ('R297_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R297_PAWN_REFUSAL_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$live=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R296_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R296_MEMORY_ROLLBACK_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$accepted=$false +if(Test-Path $root){throw 'R297_PREEXISTING_GUEST_FIXTURE_REFUSED'} +[void][IO.Directory]::CreateDirectory($root) +$mainBefore=Hash $live +try{ + $id=Get-Content $manifest -Raw|ConvertFrom-Json + $old=Get-Content $prior -Raw|ConvertFrom-Json + if($id.Commit -cne $env:GITHUB_SHA -or (Hash $setup) -cne $id.SetupSHA256 -or + (Hash $live) -cne $id.MainSHA256 -or + $old.SourceCommit -cne $env:GITHUB_SHA -or $old.Host -cne $HostLabel -or + $old.Status -cne 'PASS_MEMORY_ONLY_ROLLBACK_NO_USER_TEMP_TRUST'){ + throw 'R297_EXACT_CANDIDATE_OR_PRIOR_RECEIPT_NOT_PINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R297_EMBEDDED_HELPER_MISSING'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$helper=$reader.ReadToEnd()}finally{$reader.Dispose()} + $sourcePath=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path + $source=Get-Content -LiteralPath $sourcePath -Raw + $crlf=[string][char]13+[char]10 + $lf=[string][char]10 + if($helper.Replace($crlf,$lf).TrimEnd() -cne $source.Replace($crlf,$lf).TrimEnd()){ + throw 'R297_PACKAGED_HELPER_DIFFERS_FROM_SOURCE' + } + $checks.Add('EMBEDDED_HELPER_SOURCE_EXACT') + if($helper.Contains('Start-Process -FilePath $PawnPayload') -or + $helper.Contains('if(-not $pawnAlreadyCompatible -and !(Test-Path') -or + $helper.Contains('$PawnPayload=Join-Path $PayloadDir')){ + throw 'R297_UNTRUSTED_PAWN_AUTORUN_STILL_PRESENT' + } + $checks.Add('NO_AUTO_ELEVATED_PAWNIO_TEMP_LAUNCH') + $setupCode=Get-Content (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs') -Raw + if($setupCode.Contains('Extract("Payload.PawnIO_setup.exe"')){ + throw 'R297_SETUP_STILL_EXTRACTS_WRITABLE_PAWN_BINARY' + } + $checks.Add('SETUP_NEVER_STAGES_PAWN_INSTALLER_IN_TEMP') + $guardStart=$helper.IndexOf('$existingPawn=Get-PawnIOVersion',[StringComparison]::Ordinal) + $guardEnd=$helper.IndexOf('# R294: Verify pinned embedded identity',[StringComparison]::Ordinal) + if($guardStart -lt 0 -or $guardEnd -le $guardStart -or + $helper.IndexOf('$existingPawn=Get-PawnIOVersion',$guardStart+1) -ge 0){ + throw 'R297_PAWN_PREFLIGHT_BLOCK_NOT_UNIQUE' + } + $guard=$helper.Substring($guardStart,$guardEnd-$guardStart) + if(-not $guard.Contains('if(-not $pawnAlreadyCompatible){') -or + -not $guard.Contains("'EXTERNAL_INSTALL_REQUIRED'")){ + throw 'R297_PAWN_PREFLIGHT_NOT_STRICT' + } + $checks.Add('PRODUCTION_GUARD_IN_SOURCE_PRE_MUTATION') + $fake=Join-Path $root 'PawnIO_setup.exe' + [IO.File]::WriteAllBytes($fake,[byte[]](77,90,1,2,3,4,5,6)) + $originalFakeHash=Hash $fake + $absent=RunGuard $guard '' $fake + if($absent.Exit -ne 0 -or + $absent.Out -notmatch 'R297_RESULT_DEGRADED' -or + $absent.Out -match 'R297_SAFE_COMPATIBLE_REACHED' -or + $absent.Err -match 'Start-Process|cannot be started'){ + throw ('R297_FORGED_PAWN_FILE_WAS_NOT_IGNORED exit='+$absent.Exit+' stdout='+$absent.Out+' stderr='+$absent.Err) + } + $checks.Add('FORGED_TEMP_EXE_DOES_NOT_BYPASS_MISSING_DRIVER_REFUSAL') + $checks.Add('MISSING_DRIVER_RETURNS_EXTERNAL_CONSENT_REQUIRED') + $compatible=RunGuard $guard '2.2.0' $fake + if($compatible.Exit -ne 0 -or + $compatible.Out -notmatch 'R297_SAFE_COMPATIBLE_REACHED' -or + $compatible.Out -match 'R297_RESULT_DEGRADED'){ + throw ('R297_COMPATIBLE_EXISTING_DRIVER_REJECTED '+$compatible.Err) + } + $checks.Add('EXISTING_COMPATIBLE_DRIVER_REUSED') + if((Hash $fake) -cne $originalFakeHash){throw 'R297_FAKE_TEMP_INSTALLER_CHANGED_OR_RUN'} + $checks.Add('FORGED_TEMP_BINARY_UNMODIFIED') + if((Hash $live) -cne $mainBefore){throw 'R297_GUEST_MAIN_CHANGED'} + $checks.Add('GUEST_MAIN_UNCHANGED') + $checks.Add('NO_UAC_DRIVER_INSTALL_TASK_OR_PROTECTED_SERVICE_MUTATION') + $record=[ordered]@{ + Schema=1;Revision='R297';Status='PASS_NO_UNTRUSTED_PAWNIO_AUTORUN' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UACOrDriverServiceMutation=$false + FakeTempPawnInstallerIgnored=$true;MissingDriverRequiresOfficialExternalConsent=$true + ExistingCompatiblePawnIORetained=$true;MainUnchanged=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R297_GUEST_'+$HostLabel+'_NO_PAWNIO_TEMP_ELEVATION_PASS' +}finally{ + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R297';Status='FAIL_NOT_ACCEPTED';Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false} + [IO.File]::WriteAllText($receipt,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R298-PinnedPowerShellHost-Native.ps1 b/build/Test-R298-PinnedPowerShellHost-Native.ps1 new file mode 100644 index 00000000..f07ba862 --- /dev/null +++ b/build/Test-R298-PinnedPowerShellHost-Native.ps1 @@ -0,0 +1,172 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# Native, disposable Windows guest only; never invoke the real UAC boundary. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R298_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function SHA([string]$p){ + if(-not (Test-Path -LiteralPath $p -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R298_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json' +$fake=Join-Path $root 'attacker-current-directory' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R297_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R297_PAWN_REFUSAL_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$accepted=$false +$previousPath=[Environment]::GetEnvironmentVariable('PATH','Process') +$previousCwd=[Environment]::CurrentDirectory +$mainBefore=SHA $main +if(Test-Path -LiteralPath $root){throw 'R298_TEST_FIXTURE_COLLISION'} +[void][IO.Directory]::CreateDirectory($root) +try{ + $identity=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json + $old=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json + if($identity.Commit -cne $env:GITHUB_SHA -or + $identity.SetupSHA256 -cne (SHA $setup) -or + $identity.MainSHA256 -cne $mainBefore -or + $old.SourceCommit -cne $env:GITHUB_SHA -or + $old.Host -cne $HostLabel -or + $old.Status -cne 'PASS_NO_UNTRUSTED_PAWNIO_AUTORUN'){ + throw 'R298_EXACT_SOURCE_OR_PRIOR_NATIVE_PROOF_NOT_PINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $method=$type.GetMethod('BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') + $resolver=$type.GetMethod('TrustedSystemPowerShellPath',[Reflection.BindingFlags]'NonPublic,Static') + if(!$method -or !$resolver){throw 'R298_TESTABLE_ELEVATION_INFO_FACTORY_MISSING'} + $system=[IO.Path]::GetFullPath([Environment]::SystemDirectory) + $expected=[IO.Path]::Combine($system,'WindowsPowerShell','v1.0','powershell.exe') + if(-not [IO.File]::Exists($expected)){throw 'R298_SYSTEM_POWERSHELL_MISSING'} + $real=[string]$resolver.Invoke($null,$null) + if(-not [IO.Path]::IsPathRooted($real) -or + -not [string]::Equals($real,$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R298_SYSTEM_HOST_NOT_EXACT_ABSOLUTE_NATIVE_PATH' + } + $checks.Add('NATIVE_SYSTEM_POWERSHELL_HOST_ABSOLUTELY_PINNED') + foreach($segment in @($system, + [IO.Path]::Combine($system,'WindowsPowerShell'), + [IO.Path]::Combine($system,'WindowsPowerShell','v1.0'), + $expected)){ + if((([IO.File]::GetAttributes($segment)) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R298_NATIVE_HOST_PATH_IS_REPARSE' + } + } + $checks.Add('NATIVE_HOST_PATH_SEGMENTS_NOT_REPARSE') + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string]([IO.Path]::Combine($env:RUNNER_TEMP,('tbme_setup_'+('a'*32)))) + $args4[2]=[string]$app + $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) + $info=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$args4) + if(-not [string]::Equals($info.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($info.WorkingDirectory,([IO.Path]::GetDirectoryName($expected)),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R298_RUNAS_PROCESS_INFO_POINTS_OUTSIDE_SYSTEM_HOST' + } + $checks.Add('REAL_RUNAS_PROCESS_INFO_PINNED_TO_TRUSTED_SYSTEM_EXE_AND_WORKDIR') + if($info.UseShellExecute -ne $true -or $info.Verb -cne 'runas'){ + throw 'R298_ELEVATION_VERB_BEHAVIOR_CHANGED' + } + $checks.Add('ORIGINAL_UAC_RUNAS_SHELL_CONTRACT_PRESERVED') + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + $inline='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + $isEncoded=$info.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) + $isInline=$info.Arguments.StartsWith($inline,[StringComparison]::Ordinal) + if((-not $isEncoded -and -not $isInline) -or + $info.Arguments.Contains(' -File ') -or + $info.Arguments.Contains('ExecutionPolicy Bypass') -or + ($isEncoded -and $info.Arguments.Length -gt 32080) -or + ($isInline -and $info.Arguments.Length -gt 30000)){ + throw 'R298_POWER_SHELL_SCRIPT_ENTRY_WAS_WEAKENED' + } + $bootstrap=if($isEncoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($info.Arguments.Substring($prefix.Length))) + }else{$info.Arguments.Substring($inline.Length)} + if($bootstrap -notmatch '\$blob=\[Convert\]::FromBase64String' -or + -not $bootstrap.Contains('[ScriptBlock]::Create($code)')){ + throw 'R298_TRUSTED_HELPER_BOOTSTRAP_PROVENANCE_INVALID' + } + $checks.Add('TRUSTED_PE_INLINE_COMMAND_NO_TEMP_SCRIPT') + $originalEncoded=$info.Arguments + [void][IO.Directory]::CreateDirectory($fake) + $rogue=Join-Path $fake 'powershell.exe' + [IO.File]::WriteAllBytes($rogue,[byte[]]@(77,90,1,2,3,4,5,6,7,8)) + $beforeFake=SHA $rogue + $checks.Add('ATTACKER_NAMED_EXECUTABLE_FIXTURE_PRESENT') + # Only modify this test process, never user/system PATH. Do not start runas. + [Environment]::CurrentDirectory=$fake + [Environment]::SetEnvironmentVariable('PATH',($fake+';'+$previousPath),'Process') + $fakeResolved=[string]$resolver.Invoke($null,$null) + $tamper=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$args4) + if(-not [string]::Equals($fakeResolved,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($tamper.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($tamper.WorkingDirectory,$info.WorkingDirectory,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R298_ATTACKER_CWD_OR_PATH_REDIRECTED_RUNAS_TARGET' + } + $checks.Add('FAKE_CWD_AND_PATH_POWERSHELL_IGNORED') + if($tamper.Arguments -cne $originalEncoded -or $tamper.Verb -cne 'runas' -or + $tamper.UseShellExecute -ne $true){ + throw 'R298_ARGUMENTS_OR_UAC_VERB_CHANGED_BY_ATTACKER_SEARCH_PATH' + } + $checks.Add('ENCODED_ARGUMENTS_UNCHANGED_DURING_PATH_POISONING') + if((SHA $rogue) -cne $beforeFake){throw 'R298_FAKE_POWERSHELL_EXECUTED_OR_MODIFIED'} + $checks.Add('ATTACKER_FAKE_EXECUTABLE_UNCHANGED_NOT_EXECUTED') + $args4[0]=[string]'Uninstall' + $uninstall=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$args4) + if(-not [string]::Equals($uninstall.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or + $uninstall.Arguments.Contains(' -File ')){ + throw 'R298_UNINSTALL_HOST_OR_SCRIPT_UNPINNED' + } + $uninstallBootstrap=if($uninstall.Arguments.StartsWith($prefix,[StringComparison]::Ordinal)){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($uninstall.Arguments.Substring($prefix.Length))) + }elseif($uninstall.Arguments.StartsWith($inline,[StringComparison]::Ordinal)){ + $uninstall.Arguments.Substring($inline.Length) + }else{throw 'R298_UNINSTALL_TRANSPORT_UNSUPPORTED'} + if($uninstallBootstrap -cnotmatch "\-Mode 'Uninstall'" -or + $uninstallBootstrap -notmatch '\$blob=\[Convert\]::FromBase64String'){ + throw 'R298_UNINSTALL_MODE_OR_HELPER_SOURCE_UNPINNED' + } + $checks.Add('UNINSTALL_MODE_USES_SAME_ABSOLUTE_TRUSTED_HOST') + if((SHA $main) -cne $mainBefore){throw 'R298_GUEST_MAIN_WAS_CHANGED'} + $checks.Add('EXISTING_GUEST_MAIN_UNCHANGED') + $checks.Add('NO_UAC_OR_PROTECTED_SERVICE_MUTATION') + $data=[ordered]@{ + Schema=1;Revision='R298';Status='PASS_ABSOLUTE_SYSTEM_POWERSHELL_RUNAS_HOST' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UACOrProtectedServiceInvoked=$false + AbsoluteHostMatchesSystemDirectory=$true + FakeCwdAndPathExecutableIgnored=$true + BothModesPinned=$true;NoUntrustedScriptFile=$true + InstalledMainUnchanged=$true;Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R298_NATIVE_'+$HostLabel+'_PINNED_SYSTEM_RUNAS_HOST_PASS' +}finally{ + [Environment]::SetEnvironmentVariable('PATH',$previousPath,'Process') + [Environment]::CurrentDirectory=$previousCwd + if(-not $accepted){ + $failure=[ordered]@{ + Schema=1;Revision='R298';Status='FAIL_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerUntouched=$true + } + [IO.File]::WriteAllText($receipt,($failure|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 new file mode 100644 index 00000000..82fec1eb --- /dev/null +++ b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 @@ -0,0 +1,135 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# Destructive fixture operations are forbidden on the owner's Windows host. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R299_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function SHA([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function ExpectRefusal([string]$name,[Reflection.MethodInfo]$method,[object[]]$invokeArgs,[string]$pattern){ + $refused=$false + try{[void]$method.Invoke($null,$invokeArgs)} + catch{if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw}} + if(!$refused){throw ('R299_FAIL_OPEN_'+$name)} + $script:checks.Add($name) +} +$testRoot=Join-Path $env:RUNNER_TEMP ('R299_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R299_NONRECURSIVE_CLEANUP_RECEIPT.json' +$outside=Join-Path $testRoot 'external-canary' +$canary=Join-Path $outside 'do-not-delete.txt' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R298_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json') +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $testRoot)){ + throw 'R299_EXISTING_JOURNAL_OR_FIXTURE_WOULD_BE_TOUCHED' +} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$last=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or $candidate.SetupSHA256 -cne (SHA $setup) -or + $candidate.MainSHA256 -cne (SHA $main) -or + $last.SourceCommit -cne $env:GITHUB_SHA -or $last.Status -cne 'PASS_ABSOLUTE_SYSTEM_POWERSHELL_RUNAS_HOST' -or + $last.Host -cne $HostLabel){ + throw 'R299_SOURCE_GUEST_OR_PRIOR_PROOF_MISMATCH' +} +$mainBefore=SHA $main +$configBefore=SHA $config +$stateBefore=SHA $state +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllText($canary,'R299_EXTERNAL_PROTECTED_CANARY',[Text.UTF8Encoding]::new($false)) +$outsideBefore=SHA $canary +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$method=$type.GetMethod('CleanOwnedStage',[Reflection.BindingFlags]'Static,NonPublic') +$entryType=$type.GetNestedType('StageEntry',[Reflection.BindingFlags]'NonPublic') +if(!$method -or !$entryType){throw 'R299_EXPECTED_COMPILED_ENTRY_OR_CLEANUP_MISSING'} +$ctor=$entryType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic,Public', + $null,[Type[]]@([string],[string],[string]),$null) +if(!$ctor){throw 'R299_COMPILED_STAGE_ENTRY_CONSTRUCTOR_MISSING'} +$listType=[Collections.Generic.List[string]].GetGenericTypeDefinition().MakeGenericType([Type[]]@($entryType)) +$rows=[Activator]::CreateInstance($listType) +$entry=$ctor.Invoke([object[]]@('Docs/RELEASE_NOTES_v1.7.8.md','-','-')) +[void]$listType.GetMethod('Add').Invoke($rows,[object[]]@($entry)) +$args1=[object[]]::new(1);$args1[0]=$rows +$old=Join-Path $stage 'old' +$new=Join-Path $stage 'new' +$oldDocs=Join-Path $old 'Docs' +$newDocs=Join-Path $new 'Docs' +$note=Join-Path $newDocs 'RELEASE_NOTES_v1.7.8.md' +[void][IO.Directory]::CreateDirectory($old) +[void][IO.Directory]::CreateDirectory($newDocs) +foreach($file in @('owner.txt','phase.txt','index.txt','previous_version.txt')){ + $value=if($file -ceq 'owner.txt'){'TBME_UPGRADE_R92_OWNED_STAGE_V1'}else{'R299_ONLY_'+$file} + [IO.File]::WriteAllText((Join-Path $stage $file),$value) +} +[IO.File]::WriteAllText($note,'R299_NEW_MANAGED_NOTE') +$ownerBefore=SHA (Join-Path $stage 'owner.txt') +$noteBefore=SHA $note +$unknown=Join-Path $stage 'unrecognized.txt' +[IO.File]::WriteAllText($unknown,'R299_UNRECOGNIZED_KEEP') +ExpectRefusal 'UNKNOWN_FILE_REFUSED_BEFORE_DELETION' $method $args1 'Unknown file inside update stage' +if((SHA $note) -cne $noteBefore -or + (SHA (Join-Path $stage 'owner.txt')) -cne $ownerBefore -or + (SHA $unknown) -eq 'MISSING'){throw 'R299_UNKNOWN_FILE_DID_NOT_FAIL_CLOSED'} +[IO.File]::Delete($unknown) +$unknownDir=Join-Path $stage 'unrecognized-empty-directory' +[void][IO.Directory]::CreateDirectory($unknownDir) +ExpectRefusal 'UNKNOWN_EMPTY_DIRECTORY_REFUSED' $method $args1 'Unknown directory inside update stage' +if(!(Test-Path -LiteralPath $unknownDir) -or + (SHA (Join-Path $stage 'owner.txt')) -cne $ownerBefore){ + throw 'R299_UNKNOWN_EMPTY_DIRECTORY_WAS_DELETED' +} +[IO.Directory]::Delete($unknownDir,$false) +# A junction named like an expected parent must never be traversed. +New-Item -ItemType Junction -Path $oldDocs -Target $outside -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $oldDocs -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R299_NATIVE_JUNCTION_NOT_PRESENT' +} +ExpectRefusal 'EXPECTED_PARENT_JUNCTION_REFUSED' $method $args1 'reparse|symlink/junction' +if((SHA $canary) -cne $outsideBefore){throw 'R299_JUNCTION_DESTINATION_CHANGED'} +[IO.Directory]::Delete($oldDocs) +if(Test-Path -LiteralPath $oldDocs){throw 'R299_TEST_JUNCTION_LEFT_BEHIND'} +$checks.Add('EXTERNAL_CANARY_IMMUTABLE') +# Positive cleanup must work with recognized nested old/new files, without +# recursively traversing any directory or deleting unrelated application data. +[void][IO.Directory]::CreateDirectory($oldDocs) +[IO.File]::WriteAllText((Join-Path $oldDocs 'RELEASE_NOTES_v1.7.8.md'),'R299_OLD_MANAGED_NOTE') +[void]$method.Invoke($null,$args1) +if(Test-Path -LiteralPath $stage){throw 'R299_KNOWN_ONLY_STAGE_NOT_REMOVED'} +$checks.Add('EXACT_KNOWN_FILES_AND_DIRECTORIES_REMOVED_NONRECURSIVELY') +if((SHA $canary) -cne $outsideBefore -or (SHA $main) -cne $mainBefore -or + (SHA $config) -cne $configBefore -or (SHA $state) -cne $stateBefore){ + throw 'R299_EXTERNAL_OR_INSTALLED_USER_ASSET_CHANGED' +} +$checks.Add('ORIGINAL_GUEST_MAIN_CONFIG_AND_STATE_IMMUTABLE') +$result=[ordered]@{ + Schema=1;Revision='R299';Status='PASS_NONRECURSIVE_ALLOWLISTED_STAGE_CLEANUP' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerComputerTouched=$false + UnknownFilePreserved=$true;UnknownEmptyDirPreserved=$true + NativeJunctionRefused=$true;ExternalCanaryPreserved=$true + PositiveNestedStageCleaned=$true;NoStageResidue=$true + InstalledUserFilesUnchanged=$true;Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R299_NATIVE_'+$HostLabel+'_NONRECURSIVE_STAGE_CLEANUP_PASS' diff --git a/build/Test-R300-RetiredJournalRecovery-Native.ps1 b/build/Test-R300-RetiredJournalRecovery-Native.ps1 new file mode 100644 index 00000000..3ab47b44 --- /dev/null +++ b/build/Test-R300-RetiredJournalRecovery-Native.ps1 @@ -0,0 +1,158 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# No permission to run against owner desktop or mutate its running installation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R300_ONLY_DISPOSABLE_WINDOWS_RUNNER' +} +function Hash([string]$file){ + if(!(Test-Path -LiteralPath $file -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash +} +function Invoke-Recover([Reflection.MethodInfo]$method){ + [void]$method.Invoke($null,[object[]]::new(0)) +} +function Expect-Refused([string]$label,[Reflection.MethodInfo]$method,[string]$pattern){ + $refused=$false + try{Invoke-Recover $method}catch{ + if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw} + } + if(!$refused){throw ('R300_FAIL_OPEN_'+$label)} + $script:checks.Add($label) +} +$testRoot=Join-Path $env:RUNNER_TEMP ('R300_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R300_RETIRED_JOURNAL_RECEIPT.json' +$outside=Join-Path $testRoot 'external-canary' +$canary=Join-Path $outside 'never-delete.txt' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$retired=Join-Path $app '.tbme-upgrade-retired' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R299_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R299_NONRECURSIVE_CLEANUP_RECEIPT.json') +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $testRoot) -or + (Test-Path -LiteralPath $stage) -or + (Test-Path -LiteralPath $retired) -or + @((Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue)).Count -ne 0){ + throw 'R300_UNEXPECTED_STAGE_FIXTURE_OR_RUNNING_APP' +} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$old=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $candidate.SetupSHA256 -or + (Hash $main) -cne $candidate.MainSHA256 -or + $old.Revision -cne 'R299' -or + $old.Status -cne 'PASS_NONRECURSIVE_ALLOWLISTED_STAGE_CLEANUP' -or + $old.SourceCommit -cne $env:GITHUB_SHA -or + $old.Host -cne $HostLabel){ + throw 'R300_SOURCE_OR_PREVIOUS_NATIVE_PROOF_UNTRUSTED' +} +$before=@{ Main=(Hash $main);Config=(Hash $config);State=(Hash $state) } +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllText($canary,'R300_CROSS_DIRECTORY_CANARY',[Text.UTF8Encoding]::new($false)) +$canarySHA=Hash $canary +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$method=$type.GetMethod('RecoverUpgradeStageIfAny',$flags) +$retiredProp=$type.GetProperty('RetiredUpgradeRoot',$flags) +if(!$method -or !$retiredProp){throw 'R300_PRODUCTION_RECOVERY_ENTRYPOINT_MISSING'} +$actualRetired=[string]$retiredProp.GetValue($null,$null) +if(-not [string]::Equals($retired,$actualRetired,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R300_RETIRED_ROOT_NOT_EXACT_CANONICAL_SIBLING' +} +$checks.Add('EXACT_CANONICAL_RETIRED_ROOT') +$marker='TBME_UPGRADE_R92_OWNED_STAGE_V1' +function Write-Metadata([string]$directory,[string[]]$members){ + [void][IO.Directory]::CreateDirectory($directory) + foreach($name in $members){ + $value=if($name -ceq 'owner.txt'){$marker}else{'R300_TEST_'+$name} + [IO.File]::WriteAllText((Join-Path $directory $name),$value,[Text.Encoding]::ASCII) + } +} +# Emulate the filesystem state *after* a completed same-parent Directory.Move, +# but before tombstone metadata deletion. Does not assert actual process death. +Write-Metadata $stage @('owner.txt','index.txt','phase.txt','previous_version.txt','phase.tmp') +[IO.Directory]::Move($stage,$retired) +if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $retired)){ + throw 'R300_SAME_PARENT_DIRECTORY_MOVE_DID_NOT_RETIRE_STAGE' +} +$checks.Add('NATIVE_SAME_PARENT_RENAME_RETIRES_ACTIVE_STAGE') +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_POST_RENAME_RECOVERY_NOT_CLEAN'} +$checks.Add('POST_RENAME_RESTART_CLEANUP_ACCEPTED') +# The deletion sequence can be interrupted after index/phase have gone. +Write-Metadata $retired @('owner.txt','phase.txt','index.txt') +[IO.File]::Delete((Join-Path $retired 'index.txt')) +[IO.File]::Delete((Join-Path $retired 'phase.txt')) +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_OWNER_ONLY_POST_DELETE_RECOVERY_FAILED'} +$checks.Add('PARTIALLY_DELETED_METADATA_RECOVERED') +[void][IO.Directory]::CreateDirectory($retired) +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_EMPTY_RETIRED_DIR_NOT_RECOVERED'} +$checks.Add('EMPTY_FINAL_DIRECTORY_RECOVERED') +# An unexpected file must stop cleanup with both file and owner intact. +Write-Metadata $retired @('owner.txt','index.txt') +$unknown=Join-Path $retired 'unknown-owner-file.txt' +[IO.File]::WriteAllText($unknown,'R300_UNKNOWN_PRESERVE',[Text.Encoding]::ASCII) +$ownerSHA=Hash (Join-Path $retired 'owner.txt') +Expect-Refused 'UNKNOWN_RETIRED_MEMBER_REFUSED' $method 'Unknown retired upgrade member' +if((Hash $unknown) -eq 'MISSING' -or (Hash (Join-Path $retired 'owner.txt')) -cne $ownerSHA){ + throw 'R300_UNKNOWN_RETIREMENT_DATA_MUTATED' +} +[IO.File]::Delete($unknown) +# Detect actual junctions, including a correctly named intermediate subtree. +$link=Join-Path $retired 'phase.tmp' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $link -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R300_REAL_WINDOWS_REPARSE_NOT_CREATED' +} +Expect-Refused 'RETIRED_JUNCTION_REFUSED' $method 'reparse|ordinary file' +if((Hash $canary) -cne $canarySHA){throw 'R300_EXTERNAL_CANARY_CHANGED_BY_JUNCTION'} +[IO.Directory]::Delete($link) +$checks.Add('EXTERNAL_CANARY_PROTECTED') +# Invalid ownership and missing ownership with nonempty metadata fail closed. +[IO.File]::WriteAllText((Join-Path $retired 'owner.txt'),'WRONG_OWNER',[Text.Encoding]::ASCII) +Expect-Refused 'INVALID_OWNER_MARKER_REFUSED' $method 'ownership marker mismatch' +[IO.File]::Delete((Join-Path $retired 'owner.txt')) +Expect-Refused 'MISSING_OWNER_WITH_METADATA_REFUSED' $method 'lost its ownership marker' +[IO.File]::WriteAllText((Join-Path $retired 'owner.txt'),$marker,[Text.Encoding]::ASCII) +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_SAFE_METADATA_REPAIR_DID_NOT_FINISH'} +$checks.Add('KNOWN_RETIRED_STAGE_RECOVERED_AFTER_HOSTILE_TESTS') +if((Hash $main) -cne $before.Main -or + (Hash $config) -cne $before.Config -or + (Hash $state) -cne $before.State -or + (Hash $canary) -cne $canarySHA -or + (Test-Path -LiteralPath $stage)){ + throw 'R300_GUEST_OR_EXTERNAL_USER_CONTENT_CHANGED' +} +$checks.Add('EXACT_INSTALLED_FILES_AND_CANARY_UNCHANGED') +$data=[ordered]@{ + Schema=1;Revision='R300';Status='PASS_RETIRED_STAGE_RESTART_AND_HOSTILE_REFUSAL' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerComputerTouched=$false + RealProcessKilled=$false;SimulatedPostMoveStop=$true + SameParentDirectoryRename=$true;PostMoveCleanupRetried=$true + InterruptedMetadataCleanupRetried=$true;UnknownFileRefused=$true + ReparseRefused=$true;UnknownOwnerRefused=$true + InstalledMainConfigStateUnchanged=$true;NoRetiredOrActiveStageResidue=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R300_NATIVE_'+$HostLabel+'_RETIRED_STAGE_RECOVERY_PASS' diff --git a/build/Test-R301-RolledBackRestart-Native.ps1 b/build/Test-R301-RolledBackRestart-Native.ps1 new file mode 100644 index 00000000..80126edd --- /dev/null +++ b/build/Test-R301-RolledBackRestart-Native.ps1 @@ -0,0 +1,240 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Never run on the owner's live Windows host. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R301_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Expect-Fail([string]$gate,[Reflection.MethodInfo]$method,[string]$pattern){ + $refused=$false + try{[void]$method.Invoke($null,[object[]]::new(0))} + catch{if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw}} + if(!$refused){throw ('R301_FAIL_OPEN_'+$gate)} + $script:checks.Add($gate) +} +function Start-ProofChild([string]$code){ + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code)) + $system=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + if(-not [IO.File]::Exists($system)){throw 'R301_NATIVE_WINDOWS_POWERSHELL_MISSING'} + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$system + $psi.Arguments='-NoProfile -NonInteractive -EncodedCommand '+$encoded + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) +} +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$retired=Join-Path $app '.tbme-upgrade-retired' +$testRoot=Join-Path $env:RUNNER_TEMP ('R301_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R301_ROLLBACK_RETRY_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R300_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R300_RETIRED_JOURNAL_RECEIPT.json') +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $testRoot) -or (Test-Path -LiteralPath $stage) -or + (Test-Path -LiteralPath $retired) -or + @((Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue)).Count -ne 0){ + throw 'R301_EXISTING_OR_RUNNING_OWNER_STATE' +} +$m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($m.Commit -cne $env:GITHUB_SHA -or $m.SetupSHA256 -cne (Hash $setup) -or + $m.MainSHA256 -cne (Hash $main) -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_RETIRED_STAGE_RESTART_AND_HOSTILE_REFUSAL'){ + throw 'R301_UNTRUSTED_EXACT_SOURCE_OR_PRIOR_PROOF' +} +[void][IO.Directory]::CreateDirectory($testRoot) +$before=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$marker='TBME_UPGRADE_R92_OWNED_STAGE_V1' +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$static=[Reflection.BindingFlags]'Static,NonPublic' +$instance=[Reflection.BindingFlags]'Instance,NonPublic,Public' +$targetField=$type.GetField('UpgradeTargets',$static) +$recover=$type.GetMethod('RecoverUpgradeStageIfAny',$static) +$verify=$type.GetMethod('VerifyRolledBackUpgrade',$static) +$phaseWriter=$type.GetMethod('WritePhase',$static) +if(!$targetField -or !$recover -or !$verify -or !$phaseWriter){ + throw 'R301_EXPECTED_PRODUCTION_ENTRYPOINTS_MISSING' +} +$targets=[array]$targetField.GetValue($null) +if($targets.Count -lt 25 -or $targets.Count -gt 40){throw 'R301_UNEXPECTED_MANAGED_INVENTORY'} +$rows=[Collections.Generic.List[string]]::new() +foreach($item in $targets){ + $field=$item.GetType().GetField('Rel',$instance) + if(!$field){throw 'R301_TYPED_ROW_REL_MISSING'} + $rel=[string]$field.GetValue($item) + $full=Join-Path $app ($rel.Replace('/',[IO.Path]::DirectorySeparatorChar)) + $previous=Hash $full + if($previous -ceq 'MISSING'){$previous='-'} + elseif($previous -cnotmatch '^[0-9A-F]{64}$'){throw 'R301_BAD_HASH_GENERATED'} + # Existing source hashes are real, new-only files are represented by '-'. + $rows.Add($rel+'|'+$previous+'|-') +} +if(-not $rows[0].StartsWith('TaskbarMonitorEnhanced.exe|',[StringComparison]::Ordinal)){ + throw 'R301_PINNED_MAIN_ROW_NOT_FIRST' +} +$checks.Add('COMPILED_ROW_INVENTORY_EXACT_LIVE_HASHES') +$indexText=$rows.ToArray() -join ([string][char]10) +$registration='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$display=[string](Get-ItemProperty -LiteralPath $registration -ErrorAction Stop).DisplayVersion +$oldVersion='1.7.8' +if($display -cne $oldVersion){throw 'R301_GUEST_INSTALLED_VERSION_UNEXPECTED'} +function Prepare-Journal([string]$version){ + if(Test-Path -LiteralPath $stage){throw 'R301_STAGE_COLLISION'} + [void][IO.Directory]::CreateDirectory($stage) + [IO.File]::WriteAllText((Join-Path $stage 'owner.txt'),$marker,[Text.Encoding]::ASCII) + [IO.File]::WriteAllText((Join-Path $stage 'previous_version.txt'),$version,[Text.Encoding]::ASCII) + [IO.File]::WriteAllText((Join-Path $stage 'index.txt'),$indexText,[Text.Encoding]::ASCII) + [IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|COMMITTING'),[Text.Encoding]::ASCII) +} +function Restore-SafeStage(){ + if(Test-Path -LiteralPath $stage){ + foreach($name in @('phase.tmp','phase.txt','index.txt','previous_version.txt','owner.txt')){ + $path=Join-Path $stage $name + if(Test-Path -LiteralPath $path -PathType Leaf){[IO.File]::Delete($path)} + } + [IO.Directory]::Delete($stage,$false) + } +} +Prepare-Journal $oldVersion +# Mimic a completed rollback where previously copied 'old' backup snapshots +# have already been unlinked. A COMMITTING replay must not consume them again. +if((Test-Path -LiteralPath (Join-Path $stage 'old')) -or + (Test-Path -LiteralPath (Join-Path $stage 'new'))){ + throw 'R301_SHOULD_NOT_HAVE_STAGED_OLD_SNAPSHOTS' +} +$checks.Add('COMPLETE_OLD_LIVE_STATE_WITH_ABSENT_BACKUP_SNAPSHOTS') +# The separate real PowerShell worker executes the production WritePhase +# and is then forcibly terminated by this parent after a checkpoint. This +# is NOT a kill of the production Setup process inside filesystem cleanup. +$checkpoint=Join-Path $testRoot 'worker-durable-phase-ready.txt' +$setupQuoted=$setup.Replace("'","''") +$checkQuoted=$checkpoint.Replace("'","''") +$writerCode=@" +`$ErrorActionPreference='Stop' +`$assembly=[Reflection.Assembly]::LoadFile('$setupQuoted') +`$type=`$assembly.GetType('SetupProgram',`$true) +`$method=`$type.GetMethod('WritePhase',[Reflection.BindingFlags]'Static,NonPublic') +if(!`$method){throw 'R301_CHILD_PRODUCTION_PHASE_METHOD_MISSING'} +[void]`$method.Invoke(`$null,[object[]]@('ROLLED_BACK')) +[IO.File]::WriteAllText('$checkQuoted','DURABLE_ROLLBACK_PHASE_READY') +Start-Sleep -Seconds 30 +"@ +$worker=Start-ProofChild $writerCode +if(!$worker){throw 'R301_WORKER_PROCESS_DID_NOT_START'} +$watch=[Diagnostics.Stopwatch]::StartNew() +try{ + while(!(Test-Path -LiteralPath $checkpoint) -and !$worker.HasExited -and $watch.ElapsedMilliseconds -lt 14000){ + Start-Sleep -Milliseconds 100 + } + if(!(Test-Path -LiteralPath $checkpoint) -or $worker.HasExited){ + throw 'R301_CHILD_DURABLE_PHASE_CHECKPOINT_NOT_REACHED' + } + $phaseContent=[IO.File]::ReadAllText((Join-Path $stage 'phase.txt'),[Text.Encoding]::ASCII) + if($phaseContent -cne ($marker+'|ROLLED_BACK')){ + throw 'R301_PHASE_NOT_DURABLY_REPLACED' + } + $worker.Kill() + if(!$worker.WaitForExit(10000) -or !$worker.HasExited -or $worker.ExitCode -eq 0){ + throw 'R301_WORKER_TERMINATION_NOT_OBSERVED' + } + $checks.Add('REAL_DISPOSABLE_WORKER_KILLED_AFTER_PRODUCTION_ROLLBACK_MARKER') +}finally{ + if(!$worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(10000)} + $worker.Dispose() +} +# New process loads the SAME compiled installer and runs its normal recovery. +$retryCode=@" +`$ErrorActionPreference='Stop' +`$assembly=[Reflection.Assembly]::LoadFile('$setupQuoted') +`$type=`$assembly.GetType('SetupProgram',`$true) +`$method=`$type.GetMethod('RecoverUpgradeStageIfAny',[Reflection.BindingFlags]'Static,NonPublic') +if(!`$method){throw 'R301_RESTART_RECOVERY_METHOD_MISSING'} +[void]`$method.Invoke(`$null,[object[]]::new(0)) +"@ +$retry=Start-ProofChild $retryCode +if(!$retry){throw 'R301_RESTART_WORKER_NOT_STARTED'} +try{ + if(!$retry.WaitForExit(18000) -or !$retry.HasExited -or $retry.ExitCode -ne 0){ + $errorText=if($retry.HasExited){$retry.StandardError.ReadToEnd()}else{'CHILD_TIMEOUT'} + if($errorText.Length -gt 2500){$errorText=$errorText.Substring(0,2500)} + throw ('R301_RECOVERY_WORKER_FAILED exit='+$(if($retry.HasExited){$retry.ExitCode}else{'RUNNING'})+' stderr='+$errorText) + } +}finally{ + if(!$retry.HasExited){$retry.Kill();[void]$retry.WaitForExit(10000)} + $retry.Dispose() +} +if((Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $retired)){ + throw 'R301_POST_RESTART_JOURNAL_RESIDUE' +} +$checks.Add('FRESH_PROCESS_RESTART_RECOVERS_WITHOUT_OLD_SNAPSHOTS') +# Negative test: wrong installed digest must fail closed, preserving the stage. +Prepare-Journal $oldVersion +$first=$rows[0].Split('|') +if($first.Length -ne 3 -or $first[1] -ceq '-'){throw 'R301_EXPECTED_REAL_MAIN_PREVIOUS_HASH_MISSING'} +$changed=$first[0]+'|'+('0'*64)+'|'+$first[2] +$lines=[string[]]$rows.ToArray() +$lines[0]=$changed +[IO.File]::WriteAllText((Join-Path $stage 'index.txt'),($lines -join ([string][char]10)),[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +Expect-Fail 'FORGED_PREVIOUS_DIGEST_REJECTED' $recover 'Rolled-back previous SHA mismatch' +if(-not (Test-Path -LiteralPath $stage) -or + (Hash $main) -cne $before.Main){throw 'R301_FAILED_VERIFICATION_CHANGED_APP'} +Restore-SafeStage +# A supported but wrong prior version and HKCU version mismatch must fail closed. +Prepare-Journal '1.7.7' +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +Expect-Fail 'VERSION_REGISTRY_MISMATCH_REFUSED' $recover 'Rolled-back uninstall registration mismatch' +Restore-SafeStage +# Unrecognized version should be rejected before any delete. +Prepare-Journal '99.99' +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +Expect-Fail 'UNKNOWN_OLD_VERSION_REFUSED' $recover 'not pinned' +Restore-SafeStage +if((Hash $main) -cne $before.Main -or (Hash $config) -cne $before.Config -or + (Hash $state) -cne $before.State -or + [string](Get-ItemProperty -LiteralPath $registration).DisplayVersion -cne $oldVersion -or + (Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $retired)){ + throw 'R301_INSTALLED_ASSETS_OR_REGISTRY_CHANGED' +} +$checks.Add('GUEST_MAIN_CONFIG_STATE_AND_REGISTRATION_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R301';Status='PASS_ROLLED_BACK_JOURNAL_RETRY_REAL_WORKER_TERMINATION' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerTouched=$false;OriginalMainAndSettingsUnchanged=$true + ActualOriginalSetupProcessKilled=$false + ActualDisposableProofWorkerKilled=$true + ProductionWritePhaseExecutedInChild=$true + FreshRecoveryProcessSucceeded=$true + MissingOldSnapshotsAcceptedAfterVerifiedRollback=$true + TamperedPriorFileDigestRejected=$true + RegistryVersionMismatchRejected=$true + UnsupportedVersionRejected=$true + NoJournalResidue=$true;Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R301_NATIVE_'+$HostLabel+'_RECOVERY_AFTER_REAL_WORKER_KILL_PASS' diff --git a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 new file mode 100644 index 00000000..875edc28 --- /dev/null +++ b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 @@ -0,0 +1,440 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# User's running desktop must never receive staged files or process kills. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R302_ONLY_FRESH_GITHUB_HOSTED_WINDOWS_GUEST' +} + +function SHA([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$retired=Join-Path $app '.tbme-upgrade-retired' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R301_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R301_ROLLBACK_RETRY_RECEIPT.json') +$root=Join-Path $env:RUNNER_TEMP ('R302_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json' +$registry='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$program=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$backupConfig=Join-Path $app 'config.json.bak' +$userState=Join-Path $app 'install_state.json' +$setupInstalled=Join-Path $app 'Uninstall.exe' +$native=$null;$retry=$null;$watcher=$null +$phase='INITIAL';$accepted=$false;$failure='' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $retired) -or + (Test-Path -LiteralPath $root) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R302_GUEST_HAS_UNEXPECTED_JOURNAL_OR_RUNNING_MAIN' +} +[void][IO.Directory]::CreateDirectory($root) +$source=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($source.Commit -cne $env:GITHUB_SHA -or + $source.SetupSHA256 -cne (SHA $setup) -or + $source.MainSHA256 -cne (SHA $program) -or + $previous.Host -cne $HostLabel -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Status -cne 'PASS_ROLLED_BACK_JOURNAL_RETRY_REAL_WORKER_TERMINATION'){ + throw 'R302_SOURCE_OR_PREDECESSOR_RECEIPT_UNVERIFIED' +} +$before=[ordered]@{} +foreach($file in @($program,$config,$backupConfig,$userState,$setupInstalled)){ + $before[$file]=SHA $file +} +if($before[$setupInstalled] -cne $source.SetupSHA256){ + throw 'R302_INSTALLED_GUEST_SETUP_NOT_EXACT_CANDIDATE' +} +$display=[string](Get-ItemProperty -LiteralPath $registry -ErrorAction Stop).DisplayVersion +if($display -cne '1.7.8'){throw 'R302_GUEST_INSTALLED_VERSION_NOT_MATCHED'} +$checks.Add('GUEST_MAIN_SETUP_CONFIG_STATE_AND_REGISTRY_PINNED') + +# .NET Framework 4.8 compatibility: callback kills exactly the Process object +# started below. No process-name search and no host-wide/process-tree kill. +$sourceCode=@' +using System; +using System.IO; +using System.Diagnostics; +using System.Threading; +using System.Text; +using System.ComponentModel; +using System.Runtime.InteropServices; +// R320 test-only: create the UNMODIFIED candidate Setup suspended, arm and +// bind the watcher to its exact process ID, then resume. IDLE priority gives +// the watcher a better chance to preempt fast NTFS cleanup on Windows 2022. +public static class R320SuspendedSetupLauncher { + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] + private struct STARTUPINFO { + public uint cb;public string lpReserved;public string lpDesktop;public string lpTitle; + public uint dwX;public uint dwY;public uint dwXSize;public uint dwYSize; + public uint dwXCountChars;public uint dwYCountChars;public uint dwFillAttribute; + public uint dwFlags;public ushort wShowWindow;public ushort cbReserved2; + public IntPtr lpReserved2;public IntPtr hStdInput;public IntPtr hStdOutput;public IntPtr hStdError; + } + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION { + public IntPtr hProcess;public IntPtr hThread;public uint dwProcessId;public uint dwThreadId; + } + [DllImport("kernel32.dll",EntryPoint="CreateProcessW",CharSet=CharSet.Unicode,SetLastError=true)] + private static extern bool CreateProcess(string app,StringBuilder command, + IntPtr procSecurity,IntPtr threadSecurity,bool inheritHandles,uint flags, + IntPtr environment,string workingDir,ref STARTUPINFO startup,out PROCESS_INFORMATION process); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool TerminateProcess(IntPtr process,uint exit); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool GetExitCodeProcess(IntPtr handle,out uint exitCode); + public static bool NonzeroNativeExitCode(Process process) { + if(process==null || !process.HasExited) + throw new InvalidOperationException("R320_NATIVE_EXIT_QUERY_BEFORE_TERMINATION"); + uint exit; + if(!GetExitCodeProcess(process.Handle,out exit)) + throw new Win32Exception(Marshal.GetLastWin32Error(),"R320_NATIVE_EXITCODE_QUERY_FAILED"); + return exit!=0 && exit!=259; // STILL_ACTIVE and success are not kill witnesses + } + public static Process StartOriginalSuspended(string setup,string args,string directory,R302RealSetupDeleteWatcher watcher){ + if(watcher==null || String.IsNullOrWhiteSpace(setup) || + !Path.IsPathRooted(setup) || !File.Exists(setup) || + args!="/update /quiet" || !Directory.Exists(directory) || + !String.Equals(Path.GetDirectoryName(Path.GetFullPath(setup)),Path.GetFullPath(directory), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("R320_TEST_ONLY_UNTRUSTED_SETUP_LAUNCH_ARGUMENT"); + STARTUPINFO si=new STARTUPINFO(); + si.cb=(uint)Marshal.SizeOf(typeof(STARTUPINFO)); + PROCESS_INFORMATION pi; + StringBuilder line=new StringBuilder("\""+Path.GetFullPath(setup)+"\" "+args); + const uint CREATE_SUSPENDED=0x00000004,IDLE_PRIORITY_CLASS=0x00000040,CREATE_NO_WINDOW=0x08000000; + if(!CreateProcess(Path.GetFullPath(setup),line,IntPtr.Zero,IntPtr.Zero,false, + CREATE_SUSPENDED|IDLE_PRIORITY_CLASS|CREATE_NO_WINDOW, + IntPtr.Zero,directory,ref si,out pi)) + throw new Win32Exception(Marshal.GetLastWin32Error(),"R320_ORIGINAL_SETUP_CREATE_SUSPENDED_FAILED"); + bool resumed=false;Process child=null; + try{ + child=Process.GetProcessById(checked((int)pi.dwProcessId)); + // Materialize and retain the actual native handle before resuming, so + // GetExitCodeProcess remains usable after this short-lived process dies. + if(child.Handle==IntPtr.Zero) + throw new InvalidOperationException("R320_PROCESS_HANDLE_NOT_PINNED"); + watcher.Bind(child); // guaranteed before the suspended primary thread runs + uint previous=ResumeThread(pi.hThread); + if(previous!=1)throw new Win32Exception(Marshal.GetLastWin32Error(),"R320_ORIGINAL_SETUP_RESUME_FAILED"); + resumed=true; + return child; + }finally{ + if(!resumed){ + if(pi.hProcess!=IntPtr.Zero)TerminateProcess(pi.hProcess,125); + if(child!=null)child.Dispose(); + } + if(pi.hThread!=IntPtr.Zero)CloseHandle(pi.hThread); + if(pi.hProcess!=IntPtr.Zero)CloseHandle(pi.hProcess); + } + } +} +public sealed class R302RealSetupDeleteWatcher : IDisposable { + public readonly ManualResetEventSlim TerminatedEvent=new ManualResetEventSlim(false); + private FileSystemWatcher oldWatcher, newWatcher; + private Process child; + private int once, notifications, nativeObservations, disposed; + private IntPtr nativeNotify=IntPtr.Zero; + private Thread nativeObserver; + private string[] ownedStageFiles; + public string TriggerFile=""; + public string TriggerOrigin=""; + public string Failure=""; + public bool Killed=false; + public int Notifications { get { return notifications; } } + public int NativeKnownDeleteObservations { get { return nativeObservations; } } + [DllImport("kernel32.dll",EntryPoint="FindFirstChangeNotificationW",CharSet=CharSet.Unicode,SetLastError=true)] + private static extern IntPtr FindFirstChangeNotification(string path,bool subtree,uint filter); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool FindNextChangeNotification(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool FindCloseChangeNotification(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern uint WaitForSingleObject(IntPtr handle,uint milliseconds); + private static bool KnownLeaf(string path){ + string ext=Path.GetExtension(path).ToLowerInvariant(); + return ext==".exe" || ext==".cs" || ext==".md" || + ext==".json" || ext==".config" || ext==".ico"; + } + public void Arm(string oldPath,string newPath){ + if(!Directory.Exists(oldPath) || !Directory.Exists(newPath) || + !String.Equals(Path.GetDirectoryName(Path.GetFullPath(oldPath)), + Path.GetDirectoryName(Path.GetFullPath(newPath)), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("R330_NATIVE_WATCH_PATHS_NOT_SIBLINGS"); + var originals=new System.Collections.Generic.List(); + foreach(string directory in new string[]{oldPath,newPath}) + foreach(string file in Directory.GetFiles(directory,"*",SearchOption.AllDirectories)) + if(KnownLeaf(file)) originals.Add(Path.GetFullPath(file)); + if(originals.Count<20 || originals.Count>100) + throw new InvalidOperationException("R330_STAGE_LEAF_COUNT_OUT_OF_BOUNDS"); + ownedStageFiles=originals.ToArray(); + oldWatcher=CreateManagedObserver(oldPath); + newWatcher=CreateManagedObserver(newPath); + // The Win32 directory notification is armed before resuming the original + // test-only Setup. Managed FileSystemWatcher callback delivery can be late. + string parent=Path.GetDirectoryName(Path.GetFullPath(oldPath)); + nativeNotify=FindFirstChangeNotification(parent,true,0x00000001u|0x00000002u); + if(nativeNotify==IntPtr.Zero || nativeNotify==new IntPtr(-1)) + throw new Win32Exception(Marshal.GetLastWin32Error(),"R330_NATIVE_DIR_NOTIFY_ARM_FAILED"); + oldWatcher.EnableRaisingEvents=true; + newWatcher.EnableRaisingEvents=true; + } + private FileSystemWatcher CreateManagedObserver(string path){ + var watcher=new FileSystemWatcher(path); + watcher.IncludeSubdirectories=true; + watcher.Filter="*"; + watcher.NotifyFilter=NotifyFilters.FileName | NotifyFilters.DirectoryName; + watcher.InternalBufferSize=32768; + watcher.Deleted+=OnDeleted; + return watcher; + } + public void Bind(Process p){ + if(p==null || ownedStageFiles==null || nativeNotify==IntPtr.Zero) + throw new InvalidOperationException("R330_NATIVE_OBSERVER_NOT_ARMED"); + Interlocked.Exchange(ref child,p); + nativeObserver=new Thread(NativeObserveKnownStageDeletion); + nativeObserver.IsBackground=true; + nativeObserver.Priority=ThreadPriority.Highest; + nativeObserver.Start(); + } + private void NativeObserveKnownStageDeletion(){ + while(Interlocked.CompareExchange(ref disposed,0,0)==0 && + Interlocked.CompareExchange(ref once,0,0)==0){ + uint result=WaitForSingleObject(nativeNotify,200); + if(result==0x00000102u)continue; // WAIT_TIMEOUT + if(result!=0){Failure="NATIVE_NOTIFY_WAIT_FAILED_"+result;break;} + foreach(string file in ownedStageFiles){ + if(File.Exists(file))continue; + Interlocked.Increment(ref nativeObservations); + AttemptTerminateExactChild(file,"NATIVE_KNOWN_STAGE_LEAF_DELETION"); + return; + } + if(!FindNextChangeNotification(nativeNotify)){ + Failure="NATIVE_NOTIFY_REARM_FAILED_"+Marshal.GetLastWin32Error(); + break; + } + } + } + private void OnDeleted(object sender,FileSystemEventArgs e){ + Interlocked.Increment(ref notifications); + if(!KnownLeaf(e.FullPath))return; + bool owned=false; + foreach(string original in ownedStageFiles) + if(String.Equals(original,e.FullPath,StringComparison.OrdinalIgnoreCase)){ + owned=true;break; + } + if(owned)AttemptTerminateExactChild(e.FullPath,"FILESYSTEMWATCHER_KNOWN_STAGE_LEAF_DELETION"); + } + private void AttemptTerminateExactChild(string knownStageFile,string mechanism){ + if(Interlocked.CompareExchange(ref once,1,0)!=0)return; + TriggerFile=Path.GetFileName(knownStageFile); + TriggerOrigin=mechanism; + try{ + Process exact=child; + if(exact==null)throw new Exception("PROCESS_NOT_BOUND"); + exact.Refresh(); + if(exact.HasExited)throw new Exception("SETUP_ALREADY_EXITED"); + exact.Kill(); + Killed=exact.WaitForExit(10000) && exact.HasExited && + R320SuspendedSetupLauncher.NonzeroNativeExitCode(exact); + if(!Killed)Failure="EXACT_CHILD_TERMINATION_UNCONFIRMED"; + }catch(Exception err){Failure=err.GetType().Name+":"+err.Message;} + finally{TerminatedEvent.Set();} + } + public void Dispose(){ + Interlocked.Exchange(ref disposed,1); + if(nativeObserver!=null && !nativeObserver.Join(2000)) + throw new InvalidOperationException("R330_NATIVE_OBSERVER_THREAD_NOT_STOPPED"); + if(nativeNotify!=IntPtr.Zero && nativeNotify!=new IntPtr(-1)){ + FindCloseChangeNotification(nativeNotify);nativeNotify=IntPtr.Zero; + } + if(oldWatcher!=null){oldWatcher.EnableRaisingEvents=false;oldWatcher.Dispose();} + if(newWatcher!=null){newWatcher.EnableRaisingEvents=false;newWatcher.Dispose();} + } +} +'@ +Add-Type -TypeDefinition $sourceCode -ErrorAction Stop + +# Build a genuine ROLLED_BACK journal; hashes are from the live disposable +# installed guest. Stage payload files are disposable and exactly named by +# the candidate's compiled inventory, never unknown owner data. +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$static=[Reflection.BindingFlags]'Static,NonPublic' +$instance=[Reflection.BindingFlags]'Instance,NonPublic,Public' +$field=$type.GetField('UpgradeTargets',$static) +if(!$field){throw 'R302_COMPILED_TARGET_INVENTORY_MISSING'} +$targets=[array]$field.GetValue($null) +if($targets.Count -lt 25 -or $targets.Count -gt 40){throw 'R302_UNEXPECTED_TARGET_COUNT'} +$lines=[Collections.Generic.List[string]]::new() +$staged=0 +$old=Join-Path $stage 'old' +$new=Join-Path $stage 'new' +[void][IO.Directory]::CreateDirectory($old) +[void][IO.Directory]::CreateDirectory($new) +foreach($target in $targets){ + $relField=$target.GetType().GetField('Rel',$instance) + if(!$relField){throw 'R302_TARGET_REL_FIELD_MISSING'} + $rel=[string]$relField.GetValue($target) + if(!$rel -or $rel -match '(^|[/\\])\.\.([/\\]|$)' -or + [IO.Path]::IsPathRooted($rel) -or $rel -match '[:*?"<>|]'){ + throw 'R302_UNSAFE_COMPILED_TARGET' + } + $live=Join-Path $app ($rel.Replace('/',[IO.Path]::DirectorySeparatorChar)) + $prev=SHA $live + if($prev -ceq 'MISSING'){$prev='-'} + if($prev -cne '-' -and $prev -cnotmatch '^[A-F0-9]{64}$'){ + throw 'R302_BAD_PINNED_PREVIOUS_DIGEST' + } + $lines.Add($rel+'|'+$prev+'|-') + foreach($part in @($old,$new)){ + $name=Join-Path $part ($rel.Replace('/',[IO.Path]::DirectorySeparatorChar)) + $parent=[IO.Path]::GetDirectoryName($name) + [void][IO.Directory]::CreateDirectory($parent) + [IO.File]::WriteAllText($name,('R302_STAGE_ONLY_'+$HostLabel+'_'+$rel),[Text.UTF8Encoding]::new($false)) + $staged++ + } +} +if($staged -ne ($targets.Count*2)){throw 'R302_STAGE_FIXTURE_COUNT_INCORRECT'} +$marker='TBME_UPGRADE_R92_OWNED_STAGE_V1' +[IO.File]::WriteAllText((Join-Path $stage 'owner.txt'),$marker,[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'index.txt'), + ($lines.ToArray() -join ([string][char]10)),[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'previous_version.txt'),'1.7.8',[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +$checks.Add('COMPLETE_PINNED_ROLLED_BACK_JOURNAL_WITH_KNOWN_STAGE_PAYLOADS') + +try { + $phase='START_EXACT_ORIGINAL_SETUP_AND_ARM_DELETE_WATCH' + $watcher=[R302RealSetupDeleteWatcher]::new() + $watcher.Arm($old,$new) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup + $psi.Arguments='/update /quiet' + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName($setup) + $native=[R320SuspendedSetupLauncher]::StartOriginalSuspended( + $setup,'/update /quiet',[IO.Path]::GetDirectoryName($setup),$watcher) + if(!$native){throw 'R302_ORIGINAL_SETUP_FAILED_TO_START'} + $realSetupPid=$native.Id + $checks.Add('R320_EXACT_ORIGINAL_SETUP_LAUNCHED_SUSPENDED_WATCHER_BOUND_BEFORE_RESUME') + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not $watcher.TerminatedEvent.Wait(75) -and $clock.ElapsedMilliseconds -lt 45000){ + $native.Refresh() + if($native.HasExited){break} + } + $native.Refresh() + if(!$watcher.Killed){ + throw ('R302_SETUP_NOT_KILLED_ON_A_KNOWN_STAGE_FILE_DELETE; cause='+ + $watcher.Failure+' events='+$watcher.Notifications+ + ' setupExited='+$native.HasExited) + } + if(!$native.HasExited -or -not [R320SuspendedSetupLauncher]::NonzeroNativeExitCode($native)){ + throw 'R302_ACTUAL_SETUP_PID_TERMINATION_NOT_PROVEN' + } + $checks.Add('REAL_ORIGINAL_SETUP_PID_KILLED_BY_NATIVE_FILE_DELETION_EVENT') + $activeRemaining=@(Get-ChildItem $stage -Recurse -File -ErrorAction SilentlyContinue).Count + $retiredRemaining=@(Get-ChildItem $retired -Recurse -File -ErrorAction SilentlyContinue).Count + if(($activeRemaining+$retiredRemaining) -le 0 -or + (!(Test-Path $stage) -and !(Test-Path $retired))){ + throw 'R302_NO_PARTIAL_JOURNAL_WITNESS_AFTER_SETUP_KILL' + } + $checks.Add('SOME_RECOVERABLE_STAGE_METADATA_SURVIVES_ACTUAL_SETUP_KILL') + $phase='COLD_RETRY_EXACT_ORIGINAL_SETUP' + $retry=[Diagnostics.Process]::Start($psi) + if(!$retry){throw 'R302_COLD_RESTART_SETUP_NOT_LAUNCHED'} + $retryPid=$retry.Id + if(!$retry.WaitForExit(90000)){ + $retry.Kill();[void]$retry.WaitForExit(9000) + throw 'R302_COLD_RETRY_EXCEEDED_BOUNDED_BUDGET' + } + if($retry.ExitCode -ne 0){ + throw ('R302_NATIVE_SETUP_RECOVERY_NONZERO_'+$retry.ExitCode) + } + if((Test-Path $stage) -or (Test-Path $retired)){ + throw 'R302_RECOVERY_DID_NOT_CLEAR_BOTH_JOURNAL_NAMES' + } + $checks.Add('EXACT_SAME_BINARY_RESTART_RETIRES_PARTIAL_JOURNAL') + foreach($path in $before.Keys){ + if((SHA $path) -cne $before[$path]){ + throw ('R302_GUEST_DATA_BINARY_OR_SETTING_CHANGED_'+$path) + } + } + if([string](Get-ItemProperty -LiteralPath $registry).DisplayVersion -cne $display){ + throw 'R302_HKCU_INSTALLED_VERSION_CHANGED' + } + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R302_UNEXPECTED_BACKGROUND_APP_STARTED' + } + $checks.Add('BYTE_EXACT_SETUP_MAIN_USER_SETTINGS_AND_REGISTRY_PRESERVED') + $data=[ordered]@{ + Schema=1;Revision='R302';Status='PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UnmodifiedCandidateSetupExecuted=$true + NativeExactSetupSuspendedBoundBeforeResume=$true + ActualSetupPid=$realSetupPid;ActualSetupProcessTerminated=$true + DeletedStageFileTrigger=$watcher.TriggerFile + FileDeletionNotifications=$watcher.Notifications + NativeKnownDeletedLeafObservations=$watcher.NativeKnownDeleteObservations + NativeWatcherOrFileSystemWatcherOrigin=$watcher.TriggerOrigin + JournalFilesAfterKill=($activeRemaining+$retiredRemaining) + RecoveredBySecondActualSetupProcess=$true;SecondSetupPid=$retryPid + MainSetupConfigStateAndRegistryPreserved=$true + NoStageOrRetiredResidue=$true + CurrentVersion='1.7.8';Tests=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R302_'+$HostLabel+'_EXACT_UNMODIFIED_SETUP_KILL_AND_RESTART_PASS' +}catch{ + $failure=$_.Exception.ToString() + throw +}finally{ + if($watcher){$watcher.Dispose()} + foreach($process in @($native,$retry)){ + if($null -ne $process){ + try{ + $process.Refresh() + if(!$process.HasExited){ + $process.Kill() + [void]$process.WaitForExit(10000) + } + }finally{$process.Dispose()} + } + } + if(!$accepted){ + $bad=[ordered]@{ + Schema=1;Revision='R302';Status='FAIL_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Phase=$phase;Failure=$failure + OriginalOwnerComputerTouched=$false + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($bad|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-R303-ProtectedSensorOwner-Native.ps1 b/build/Test-R303-ProtectedSensorOwner-Native.ps1 new file mode 100644 index 00000000..80024f0c --- /dev/null +++ b/build/Test-R303-ProtectedSensorOwner-Native.ps1 @@ -0,0 +1,235 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R303_ONLY_DISPOSABLE_WINDOWS' +} +function SHA([string]$p){ + if(!(Test-Path -LiteralPath $p -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R303_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R303_PROTECTED_SENSOR_OWNER_RECEIPT.json' +$childFile=Join-Path $root 'guard-child.ps1' +$broker=Join-Path $root 'simulated-protected-broker' +$markerFile=Join-Path $broker '.tbme-owner.sid' +$external=Join-Path $root 'external-canary' +$canary=Join-Path $external 'untouched.txt' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R302_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R303_TEST_ROOT_PREEXISTS'} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + $candidate.SetupSHA256 -cne (SHA $setup) -or + $candidate.MainSHA256 -cne (SHA $main) -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED'){ + throw 'R303_UNTRUSTED_CANDIDATE_OR_PREDECESSOR_NATIVE_RECEIPT' +} +$before=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$sid=[string][Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R303_NOT_INTERACTIVE_SID'} +if(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue){ + throw 'R303_GUEST_ALREADY_HAS_SENSOR_TASK' +} +if(@(Get-Process TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count -gt 0){ + throw 'R303_GUEST_ALREADY_HAS_SENSOR_PROCESS' +} +[void][IO.Directory]::CreateDirectory($root) +[void][IO.Directory]::CreateDirectory($external) +[IO.File]::WriteAllText($canary,'R303_EXTERNAL_CANARY_PRESERVE',[Text.UTF8Encoding]::new($false)) +$canarySHA=SHA $canary +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($helper) +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R303_PRODUCTION_HELPER_PS51_PARSE_FAILED'} +$defs=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Assert-OrRecordSensorOwner') +},$true)) +if($defs.Count -ne 2){throw 'R303_EXPECTED_TWO_REAL_GUARD_FUNCTIONS'} +$begin='# R264-SENSOR-GUARD-BOUNDARY-BEGIN' +$end='# R264-SENSOR-GUARD-BOUNDARY-END' +$i=$raw.IndexOf($begin,[StringComparison]::Ordinal) +$j=$raw.IndexOf($end,[StringComparison]::Ordinal) +if($i -lt 0 -or $j -le $i -or + $raw.IndexOf($begin,$i+1,[StringComparison]::Ordinal) -ge 0 -or + $raw.IndexOf($end,$j+1,[StringComparison]::Ordinal) -ge 0){ + throw 'R303_PRODUCTION_SID_GUARD_SPAN_NOT_UNIQUE' +} +$guard=$raw.Substring($i,$j+$end.Length-$i) +$install=$raw.IndexOf('$taskInstalled=$false',[StringComparison]::Ordinal) +$markerCall=$raw.IndexOf(' Assert-OrRecordSensorOwner',[StringComparison]::Ordinal) +$stop=$raw.IndexOf(' Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue |',$install,[StringComparison]::Ordinal) +if($markerCall -lt 0 -or $install -le $markerCall -or $stop -le $install){ + throw 'R303_OWNER_MARKER_MUST_BE_PERSISTED_PRE_MUTATION' +} +$checks.Add('EXACT_SOURCE_MARKER_BEFORE_PROTECTED_TASK_OR_PROCESS_MUTATION') +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R303_EMBEDDED_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8,$true) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$normalize={param([string]$s) return $s.Replace([string][char]13+[string][char]10,[string][char]10).Replace([string][char]13,[string][char]10)} +if((& $normalize $raw) -cne (& $normalize $embedded)){ + throw 'R303_REVIEW_HELPER_AND_PE_EMBEDDED_TEXT_DIFFER' +} +$checks.Add('PE_EMBEDDED_HELPER_EXACT_SOURCE_MATCH') +$header=@' +[CmdletBinding()] +param([string]$Scenario,[string]$Broker,[string]$Result) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$BrokerRoot=$Broker +$SensorOwnerMarker=Join-Path $BrokerRoot '.tbme-owner.sid' +$validatedSensorSid=[string][Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$UserId=[string][Security.Principal.WindowsIdentity]::GetCurrent().Name +$TaskName='TaskbarMonitorEnhanced Sensor Broker' +$Mode='Install' +$ActiveArchitecture='UNKNOWN' +function Write-Log([string]$value){} +function Write-Result([string]$Status){ + [IO.File]::WriteAllText($Result,$Status,[Text.Encoding]::ASCII) +} +'@ +$beforeGuard=@' +if($Scenario -ceq 'Record'){ + Assert-OrRecordSensorOwner + [IO.File]::WriteAllText($Result,'MARKER_DURABLE',[Text.Encoding]::ASCII) + Start-Sleep -Seconds 30 +}elseif($Scenario -ceq 'Guard'){ +'@ +$afterGuard=@' + [IO.File]::WriteAllText($Result,'GUARD_ACCEPTED_SAME_OWNER',[Text.Encoding]::ASCII) +}else{throw 'R303_TEST_SCENARIO_UNSUPPORTED'} +'@ +$child=$header+[Environment]::NewLine+ + (($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)+[Environment]::NewLine+ + $beforeGuard+[Environment]::NewLine+ + $guard+[Environment]::NewLine+ + $afterGuard +[IO.File]::WriteAllText($childFile,$child,[Text.UTF8Encoding]::new($false)) +$tk=$null;$err=$null +[void][Management.Automation.Language.Parser]::ParseFile($childFile,[ref]$tk,[ref]$err) +if(@($err).Count){throw 'R303_SCOPED_CHILD_SCRIPT_PARSE_FAILED'} +function Start-Child([string]$scenario,[string]$folder,[string]$result){ + $ps=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $info=[Diagnostics.ProcessStartInfo]::new() + $info.FileName=$ps + $info.Arguments='-NoProfile -NonInteractive -File "'+$childFile+'" -Scenario '+$scenario+' -Broker "'+$folder+'" -Result "'+$result+'"' + $info.UseShellExecute=$false + $info.CreateNoWindow=$true + $info.RedirectStandardError=$true + $info.RedirectStandardOutput=$true + return [Diagnostics.Process]::Start($info) +} +function Expect-Guard([string]$name,[string]$folder,[string]$expected){ + $result=Join-Path $root ($name+'.txt') + if(Test-Path -LiteralPath $result){throw 'R303_DUPLICATE_SCENARIO'} + $proc=Start-Child 'Guard' $folder $result + try{ + if(!$proc.WaitForExit(18000)){throw ('R303_GUARD_TIMED_OUT_'+$name)} + if($proc.ExitCode -ne 0){throw ('R303_GUARD_FAILED_'+$name+' '+$proc.StandardError.ReadToEnd())} + if(!(Test-Path -LiteralPath $result)){throw ('R303_MISSING_GUARD_RECEIPT_'+$name)} + $value=[IO.File]::ReadAllText($result,[Text.Encoding]::ASCII) + if($value -cne $expected){ + throw ('R303_WRONG_GUARD_'+$name+' EXPECTED='+$expected+' ACTUAL='+$value) + } + $script:checks.Add($name) + }finally{ + if(!$proc.HasExited){$proc.Kill();[void]$proc.WaitForExit(10000)} + $proc.Dispose() + } +} +$ready=Join-Path $root 'record-ready.txt' +$producer=Start-Child 'Record' $broker $ready +$watch=[Diagnostics.Stopwatch]::StartNew() +try{ + while(!(Test-Path -LiteralPath $ready) -and !$producer.HasExited -and $watch.ElapsedMilliseconds -lt 16000){ + Start-Sleep -Milliseconds 80 + } + $producer.Refresh() + if(!(Test-Path -LiteralPath $ready) -or $producer.HasExited -or + [IO.File]::ReadAllText($ready) -cne 'MARKER_DURABLE' -or + [IO.File]::ReadAllText($markerFile,[Text.Encoding]::ASCII) -cne $sid){ + throw ('R303_REAL_WRITER_DID_NOT_FLUSH_MARKER '+$(if($producer.HasExited){$producer.StandardError.ReadToEnd()}else{'TIMEOUT'})) + } + $producer.Kill() + if(!$producer.WaitForExit(10000) -or !$producer.HasExited -or $producer.ExitCode -eq 0){ + throw 'R303_TEST_WRITER_KILL_NOT_PROVEN' + } +}finally{ + if(!$producer.HasExited){$producer.Kill();[void]$producer.WaitForExit(10000)} + $producer.Dispose() +} +$checks.Add('EXCLUSIVE_OWNER_MARKER_FLUSH_BEFORE_REAL_TEST_PROCESS_KILL') +$oldSHA=SHA $markerFile +Expect-Guard 'REPAIR_CURRENT_OWNER_AFTER_PROCESS_DEATH' $broker 'GUARD_ACCEPTED_SAME_OWNER' +if((SHA $markerFile) -cne $oldSHA){throw 'R303_REPAIR_CHANGED_VALID_OWNER_MARKER'} +$checks.Add('SAME_OWNER_PROTECTED_MARKER_IMMUTABLE_ON_RETRY') +[IO.File]::Delete($markerFile) +Expect-Guard 'ROOT_WITH_MISSING_OWNER_REFUSED' $broker 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' +if(Test-Path -LiteralPath $markerFile){throw 'R303_MISSING_OWNER_WAS_CREATED_DURING_REFUSAL'} +$checks.Add('MISSING_OWNER_REFUSAL_NO_MUTATION') +[IO.File]::WriteAllText($markerFile,'S-1-5-21-0-0-0-1',[Text.Encoding]::ASCII) +Expect-Guard 'FOREIGN_OWNER_SID_REFUSED' $broker 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' +$checks.Add('OTHER_SID_REFUSAL_NO_MUTATION') +[IO.File]::WriteAllText($markerFile,$sid,[Text.Encoding]::ASCII) +Expect-Guard 'CORRECT_SID_ACCEPTED_AGAIN' $broker 'GUARD_ACCEPTED_SAME_OWNER' +$checks.Add('EXACT_SID_VALIDATION_REUSABLE') +$priorBroker=Join-Path $root 'prior-owned-root' +[IO.Directory]::Move($broker,$priorBroker) +New-Item -ItemType Junction -Path $broker -Target $external -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $broker -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R303_NATIVE_JUNCTION_NOT_CREATED' +} +Expect-Guard 'PARENT_JUNCTION_REFUSED' $broker 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' +if((SHA $canary) -cne $canarySHA){throw 'R303_EXTERNAL_CANARY_MODIFIED'} +[IO.Directory]::Delete($broker) +if(Test-Path -LiteralPath $broker){throw 'R303_JUNCTION_LINK_RESIDUE'} +[IO.Directory]::Move($priorBroker,$broker) +$checks.Add('REAL_WINDOWS_JUNCTION_REJECTED_EXTERNAL_BYTES_UNTOUCHED') +if((SHA $main) -cne $before.Main -or (SHA $config) -cne $before.Config -or + (SHA $state) -cne $before.State -or + (SHA $canary) -cne $canarySHA -or + [IO.File]::ReadAllText($markerFile,[Text.Encoding]::ASCII) -cne $sid){ + throw 'R303_GUEST_PROTECTED_USER_BYTES_CHANGED' +} +$checks.Add('GUEST_MAIN_CONFIG_STATE_AND_EXTERNAL_CANARY_PRESERVED') +$result=[ordered]@{ + Schema=1;Revision='R303';Status='PASS_PROTECTED_SENSOR_OWNER_MARKER_REPAIR_GUARD' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + PEEmbeddedSourceExact=$true + RealDisposableWriterKilledAfterMarkerFlush=$true + ActualElevatedHelperProcessKilled=$false + ActualProtectedServicesChanged=$false + MissingOrForeignOwnerRefused=$true + SameOwnerWithoutTaskAccepted=$true + ActualJunctionRefused=$true + MainAndUserDataPreserved=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R303_NATIVE_'+$HostLabel+'_PROTECTED_OWNER_RETRY_PASS' diff --git a/build/Test-R304-LongInlinePowerShell-Native.ps1 b/build/Test-R304-LongInlinePowerShell-Native.ps1 new file mode 100644 index 00000000..87be6716 --- /dev/null +++ b/build/Test-R304-LongInlinePowerShell-Native.ps1 @@ -0,0 +1,181 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R304_DISPOSABLE_WINDOWS_ONLY' +} + +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Expect-FailsClosed([string]$description,[string]$arguments,[string]$reason){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$script:trustedHost + $psi.Arguments=$arguments + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $process=[Diagnostics.Process]::Start($psi) + if(!$process){throw ('R304_PROCESS_NOT_CREATED_'+$description)} + try{ + $stdout=$process.StandardOutput.ReadToEndAsync() + $stderr=$process.StandardError.ReadToEndAsync() + if(!$process.WaitForExit(25000)){ + if(!$process.HasExited){$process.Kill();[void]$process.WaitForExit(9000)} + throw ('R304_POWERSHELL_TEST_TIMEOUT_'+$description) + } + $text=$stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult() + if($process.ExitCode -eq 0 -or $text -notmatch $reason){ + if($text.Length -gt 500){$text=$text.Substring(0,500)} + throw ('R304_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$description+' EXIT='+$process.ExitCode+' OUTPUT='+$text) + } + if(Test-Path -LiteralPath $script:sentinel){ + throw ('R304_ATTACK_SENTINEL_CREATED_'+$description) + } + $script:checks.Add($description) + }finally{$process.Dispose()} +} + +$root=Join-Path $env:RUNNER_TEMP ('R304_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R303_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R303_PROTECTED_SENSOR_OWNER_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R304_PREEXISTING_TEST_ROOT'} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $candidate.SetupSHA256 -or + (Hash $main) -cne $candidate.MainSHA256 -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_PROTECTED_SENSOR_OWNER_MARKER_REPAIR_GUARD'){ + throw 'R304_SOURCE_OR_PREVIOUS_NATIVE_RECEIPT_NOT_PINNED' +} +$original=@{ Main=(Hash $main);Config=(Hash $config);State=(Hash $state) } +[void][IO.Directory]::CreateDirectory($root) +$script:sentinel=Join-Path $root 'injection-must-not-be-executed.txt' +$script:trustedHost=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +if(-not [IO.File]::Exists($script:trustedHost)){ + throw 'R304_ABSOLUTE_SYSTEM_POWERSHELL_MISSING' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$builder=$type.GetMethod('BuildElevatedHelperCommand',$flags) +$hostMethod=$type.GetMethod('TrustedSystemPowerShellPath',$flags) +if(!$builder -or !$hostMethod){throw 'R304_COMPILED_METHODS_NOT_FOUND'} +$hostSource=[string]$hostMethod.Invoke($null,$null) +if(-not [string]::Equals($hostSource,$script:trustedHost,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R304_SYSTEM_POWER_SHELL_HOST_NOT_PINNED' +} +$checks.Add('TRUSTED_ABSOLUTE_SYSTEM_HOST_PINNED') +function Command([string]$mode,[string]$payload,[string]$application,[string]$user){ + $arguments=[object[]]::new(4) + $arguments[0]=$mode;$arguments[1]=$payload;$arguments[2]=$application;$arguments[3]=$user + return [string]$builder.Invoke($null,$arguments) +} +$user=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) +$normalPayload=Join-Path $env:RUNNER_TEMP ('tbme_setup_'+('a'*32)) +$normalRoot=Join-Path $env:WINDIR 'R304_Expected_PreWrite_Refusal' +$normal=Command Install $normalPayload $normalRoot $user +$encodedPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' +$inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' +$shortIsEncoded=$normal.StartsWith($encodedPrefix,[StringComparison]::Ordinal) +$shortIsInline=$normal.StartsWith($inlinePrefix,[StringComparison]::Ordinal) +if((-not $shortIsEncoded -and -not $shortIsInline) -or + ($shortIsEncoded -and $normal.Length -gt 32080) -or + ($shortIsInline -and $normal.Length -gt 30000) -or + $normal.Contains(' -File ')){ + throw 'R304_SHORT_UNBOUNDED_OR_UNSAFE_TRANSPORT' +} +$normalCode=if($shortIsEncoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($normal.Substring($encodedPrefix.Length))) +}else{$normal.Substring($inlinePrefix.Length)} +$match=[regex]::Match($normalCode,"(?m)^\`$blob=\[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)") +if(!$match.Success){throw 'R304_PRIOR_PE_HELPER_BLOB_NOT_ENCODED'} +$originalBlob=$match.Groups[1].Value +$checks.Add('SHORT_INMEMORY_COMMAND_PE_BLOB_PINNED') +$longName='C:\Users\'+('Q'*128) +$longPayload=$longName+'\AppData\Local\Temp\tbme_setup_'+('b'*32) +$longRoot=$longName+'\AppData\Local\TaskbarMonitorEnhanced' +$fallback=Command Install $longPayload $longRoot $user +$commandPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' +if(-not $fallback.StartsWith($commandPrefix,[StringComparison]::Ordinal) -or + $fallback.Contains(' -File ') -or $fallback.Contains('ExecutionPolicy Bypass') -or + $fallback.Length -gt 30000 -or $fallback.Contains($longRoot) -or + $fallback.Contains($longPayload)){ + throw 'R304_LONG_PROFILE_FALLBACK_USES_UNTRUSTED_FILE_OR_RAW_ARGS' +} +$fallbackScript=$fallback.Substring($commandPrefix.Length) +$fallbackMatch=[regex]::Match($fallbackScript,"(?m)^\`$blob=\[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)") +if(!$fallbackMatch.Success -or $fallbackMatch.Groups[1].Value -cne $originalBlob -or + -not $fallbackScript.Contains('[ScriptBlock]::Create($code)')){ + throw 'R304_FALLBACK_EMBEDDED_SCRIPT_PROVENANCE_CHANGED' +} +$checks.Add('LONG_PROFILE_COMMAND_FALLBACK_HAS_EXACT_TRUSTED_PE_BLOB') +$malicious=$longPayload+"'; [IO.File]::WriteAllText('"+$script:sentinel+"','INJECTED'); #" +$attack=Command Install $malicious $longRoot $user +if(-not $attack.StartsWith($commandPrefix,[StringComparison]::Ordinal) -or + $attack.Contains($malicious) -or $attack.Contains($script:sentinel)){ + throw 'R304_INJECTION_SYNTAX_ESCAPED_DATA_B64' +} +$checks.Add('INJECTION_TOKENS_ARE_NEVER_RAW_POWERSHELL_CODE') +$limitFailed=$false +try{$null=Command Install ('X'*4100) $normalRoot $user} +catch{ + if($_.Exception.ToString() -match 'missing or too long'){$limitFailed=$true} + else{throw} +} +if(!$limitFailed){throw 'R304_UNBOUNDED_PARAMETER_FAILOPEN'} +$checks.Add('PER_PARAMETER_LENGTH_CAP_PRESERVED') +# The native 5.1 child runs as the current guest user, NEVER with Verb=runas. +# All three invocations must refuse before writing any optional sensor state. +Expect-FailsClosed 'SHORT_EXACT_PE_COMMAND_REFUSED_PREWRITE' $normal 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +Expect-FailsClosed 'LONG_NEW_COMMAND_REFUSED_PREWRITE' $fallback 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +Expect-FailsClosed 'HOSTILE_LONG_COMMAND_REFUSED_PREWRITE' $attack 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +if((Hash $main) -cne $original.Main -or + (Hash $config) -cne $original.Config -or + (Hash $state) -cne $original.State -or + (Test-Path -LiteralPath $script:sentinel)){ + throw 'R304_INSTALLED_MAIN_SETTINGS_OR_INJECTION_SENTINEL_CHANGED' +} +$checks.Add('GUEST_MAIN_CONFIG_STATE_AND_SENTINEL_UNCHANGED') +$record=[ordered]@{ + Schema=1;Revision='R304' + Status='PASS_LONG_PROFILE_INMEMORY_POWERSHELL_FALLBACK' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;NoUAC=$true + OriginalOwnerComputerTouched=$false + ShortEncodedCommandUsed=$shortIsEncoded + ShortInlineTransportVerified=$shortIsInline + LongProfileFallbackCommandExecuted=$true + NoUserWritableScriptExecuted=$true + HostileInputRemainedEncoded=$true + InstalledUserFilesUnchanged=$true + ShortCommandLength=$normal.Length + LongFallbackCommandLength=$fallback.Length + HostileFallbackCommandLength=$attack.Length + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R304_'+$HostLabel+'_SHORT_LONG_HOSTILE_POWERSHELL_TRANSPORT_PASS' diff --git a/build/Test-R305-EffectiveUacTokenSid-Native.ps1 b/build/Test-R305-EffectiveUacTokenSid-Native.ps1 new file mode 100644 index 00000000..aa9383cf --- /dev/null +++ b/build/Test-R305-EffectiveUacTokenSid-Native.ps1 @@ -0,0 +1,135 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Read-only identity probes; no UAC or shared service/driver mutation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R305_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function SHA([string]$f){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash +} +function Reject([string]$label,[string]$mode,[string]$payload,[string]$app,[string]$identity,[string]$expected){ + $observed='' + try{[void](Assert-SensorPrivilegedBoundary $mode $payload $app $identity)} + catch{$observed=$_.Exception.ToString()} + if($observed -notmatch $expected){ + if($observed.Length -gt 500){$observed=$observed.Substring(0,500)} + throw ('R305_UNEXPECTED_REFUSAL_'+$label+' ACTUAL='+$observed) + } + $script:checks.Add($label) +} +$root=Join-Path $env:RUNNER_TEMP ('R305_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R305_ELEVATED_TOKEN_SID_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R304_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json') +$main=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced\TaskbarMonitorEnhanced.exe' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$marker=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'TaskbarMonitorEnhanced\SensorBroker\.tbme-owner.sid' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R305_EXACT_RUN_FIXTURE_ROOT_EXISTS'} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + $candidate.SetupSHA256 -cne (SHA $setup) -or + $candidate.MainSHA256 -cne (SHA $main) -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_LONG_PROFILE_INMEMORY_POWERSHELL_FALLBACK'){ + throw 'R305_EXACT_CANDIDATE_OR_R304_NATIVE_PROOF_NOT_PINNED' +} +[void][IO.Directory]::CreateDirectory($root) +$original=@{ Main=(SHA $main);Config=(SHA $config);State=(SHA $state);Marker=(SHA $marker) } +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($source) +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($source,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R305_WINPS51_HELPER_SYNTAX_INVALID'} +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -in @('Assert-SensorPrivilegedBoundary','Assert-SensorBoundaryNoReparse') +},$true)) +if($defs.Count -ne 2){throw 'R305_EXPECTED_TWO_GUARD_AST_NODES'} +$checkAt=$raw.IndexOf("SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH",[StringComparison]::Ordinal) +$profileAt=$raw.IndexOf('$profileKey=',[StringComparison]::Ordinal) +$logsAt=$raw.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) +if($checkAt -lt 0 -or $profileAt -le $checkAt -or $logsAt -le $profileAt){ + throw 'R305_ELEVATED_TOKEN_BOUNDARY_NOT_PREWRITE_OR_UNIQUE' +} +$checks.Add('EFFECTIVE_TOKEN_SID_CHECK_BEFORE_PROFILE_AND_LOG_WRITES') +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$resource=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R305_PRIVATE_SETUP_EMBEDDED_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $raw) -cne (& $normalize $embedded)){ + throw 'R305_HELPER_SOURCE_NOT_EXACT_SETUP_PE' +} +$checks.Add('EXACT_PE_EMBEDDED_HELPER_CONTAINS_SID_CHECK') +# Evaluate only the two pure security functions from the compiled-source AST. +# Never dot-source or invoke the side-effecting privileged installer. +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$token=[Security.Principal.WindowsIdentity]::GetCurrent() +$actualSid=[string]$token.User.Value +$actualName=[string]$token.Name +if($actualSid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'R305_GUEST_NOT_INTERACTIVE_SID' +} +$temp=Join-Path ([Environment]::GetFolderPath('UserProfile')) 'AppData\Local\Temp' +$payloadInstall=Join-Path $temp ('tbme_setup_'+('a'*32)) +$payloadUninstall=Join-Path $temp ('tbme_uninstall_'+('b'*32)) +if((Assert-SensorPrivilegedBoundary Install $payloadInstall $app $actualSid) -cne $actualSid){ + throw 'R305_SAME_SID_INSTALL_NOT_ACCEPTED' +} +$checks.Add('EXACT_TOKEN_SID_INSTALL_ACCEPTED') +if((Assert-SensorPrivilegedBoundary Uninstall $payloadUninstall $app $actualSid) -cne $actualSid){ + throw 'R305_SAME_SID_UNINSTALL_NOT_ACCEPTED' +} +$checks.Add('EXACT_TOKEN_SID_UNINSTALL_ACCEPTED') +if((Assert-SensorPrivilegedBoundary Install $payloadInstall $app $actualName) -cne $actualSid){ + throw 'R305_CURRENT_ACCOUNT_NTNAME_NOT_RESOLVED_TO_TOKEN_SID' +} +$checks.Add('CURRENT_WINDOWS_ACCOUNT_NAME_RESOLVES_TO_TOKEN_SID') +$foreign='S-1-5-21-12345-23456-34567-45678' +if($foreign -ceq $actualSid){$foreign='S-1-5-21-12345-23456-34567-45679'} +Reject 'SYNTHETIC_FOREIGN_ACCOUNT_INSTALL_PREWRITE_REJECTED' Install $payloadInstall $app $foreign 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' +Reject 'SYNTHETIC_FOREIGN_ACCOUNT_UNINSTALL_PREWRITE_REJECTED' Uninstall $payloadUninstall $app $foreign 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' +Reject 'SYSTEM_SERVICE_SID_UNRESOLVABLE' Install $payloadInstall $app 'S-1-5-18' 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' +Reject 'UNRESOLVABLE_OTHER_ACCOUNT_REFUSED' Install $payloadInstall $app 'THIS_USER_MUST_NOT_EXIST_R305' 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' +Reject 'REQUESTED_WRONG_APP_ROOT_REFUSED' Install $payloadInstall (Join-Path $env:WINDIR 'R305_REJECT_ROOT') $actualSid 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +if((SHA $main) -cne $original.Main -or (SHA $config) -cne $original.Config -or + (SHA $state) -cne $original.State -or (SHA $marker) -cne $original.Marker){ + throw 'R305_READONLY_NATIVE_TOKEN_TEST_CHANGED_USER_OR_PROTECTED_BYTES' +} +$checks.Add('MAIN_CONFIG_STATE_AND_PROTECTED_OWNER_MARKER_UNTOUCHED') +$receiptValue=[ordered]@{ + Schema=1;Revision='R305' + Status='PASS_EFFECTIVE_PROCESS_TOKEN_SID_BOUND_TO_REQUESTED_USER' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + ActualUACOrProtectedServicesInvoked=$false + PrivateEmbeddedPEHelperExact=$true + SameUserIdentityAccepted=$true + CrossAccountSyntheticSidsRejected=$true + RejectedBeforeAnyLogOrServiceWrite=$true + MainConfigStateAndMarkerUnchanged=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($receiptValue|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R305_GUEST_'+$HostLabel+'_EFFECTIVE_TOKEN_SID_POLICY=PASS' diff --git a/build/Test-R306-DurableRollbackModel.ps1 b/build/Test-R306-DurableRollbackModel.ps1 new file mode 100644 index 00000000..f96d0f04 --- /dev/null +++ b/build/Test-R306-DurableRollbackModel.ps1 @@ -0,0 +1,256 @@ +[CmdletBinding()] +param( + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R306: isolated fault-injection prototype, never elevates or touches Program Files. +if($env:OS -cne 'Windows_NT'){throw 'R306_WINDOWS_REQUIRED'} +$Source=Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1' +$sourceTokens=$null;$sourceErrors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($Source,[ref]$sourceTokens,[ref]$sourceErrors) +if(@($sourceErrors).Count){throw 'R306_PRODUCTION_PARSER_INVALID'} +$functions=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Get-SensorRollbackFiles','Assert-SensorBoundaryNoReparse') +},$true)) +if($functions.Count -ne 2){throw 'R306_PRODUCTION_CAPTURE_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($functions|ForEach-Object{$_.Extent.Text}) -join [Environment]::NewLine)) +function HashBytes([byte[]]$value){ + $sha=[Security.Cryptography.SHA256]::Create() + try{return [BitConverter]::ToString($sha.ComputeHash($value)).Replace('-','')} + finally{$sha.Dispose()} +} +function SHA([string]$name){ + if(!(Test-Path -LiteralPath $name -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $name -Algorithm SHA256).Hash +} +function WriteNewAndFlush([string]$name,[byte[]]$data){ + if([IO.File]::Exists($name)){throw ('R306_EXISTING_OUTPUT_REFUSED_'+$name)} + $parent=[IO.Path]::GetDirectoryName($name) + [void][IO.Directory]::CreateDirectory($parent) + Assert-SensorBoundaryNoReparse $parent + $stream=[IO.FileStream]::new($name,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($data,0,$data.Length);$stream.Flush($true)} + finally{$stream.Dispose()} +} +function SafeRelative([string]$rel){ + if([string]::IsNullOrEmpty($rel) -or [IO.Path]::IsPathRooted($rel) -or + $rel.Contains('/') -or $rel.Contains(':') -or + $rel.Contains('..') -or $rel.StartsWith('\') -or $rel.EndsWith('\')){ + throw 'R306_BAD_RELATIVE_SNAPSHOT_NAME' + } + foreach($p in $rel.Split('\')){ + if($p -notmatch '^[A-Za-z0-9_.-]+$' -or $p -in @('.','..')){throw ('R306_BAD_RELATIVE_SNAPSHOT_NAME REL='+$rel+' PART='+$p)} + } +} +function SaveDurable([string]$broker,[string]$journal,[string]$owner,[string]$taskXml){ + if(Test-Path -LiteralPath $journal){throw 'R306_JOURNAL_ALREADY_EXISTS'} + Assert-SensorBoundaryNoReparse $broker + Assert-SensorBoundaryNoReparse ([IO.Path]::GetDirectoryName($journal)) + $files=Get-SensorRollbackFiles $broker + if($files.Count -lt 1 -or $files.Count -gt 128){throw 'R306_CAPTURE_COUNT'} + [void][IO.Directory]::CreateDirectory($journal) + [void][IO.Directory]::CreateDirectory((Join-Path $journal 'files')) + $index=[Collections.Generic.List[object]]::new() + foreach($item in $files){ + $rel=[string]$item.Rel + SafeRelative $rel + $bytes=[byte[]]$item.Data + $path=Join-Path (Join-Path $journal 'files') $rel + WriteNewAndFlush $path $bytes + $sum=HashBytes $bytes + if((SHA $path) -cne $sum){throw 'R306_DURABLE_CAPTURE_READBACK_MISMATCH'} + $index.Add([pscustomobject]@{Rel=$rel;Length=$bytes.Length;Sha256=$sum}) + } + $taskBytes=[Text.Encoding]::UTF8.GetBytes($taskXml) + if($taskBytes.Length -gt 262144){throw 'R306_TASK_XML_OVERLIMIT'} + WriteNewAndFlush (Join-Path $journal 'task.xml') $taskBytes + $manifest=[ordered]@{ + Schema=1;Status='PREPARED';Sid=$owner; + OriginalFiles=@($index);TaskSha256=(HashBytes $taskBytes) + } + $data=[Text.Encoding]::UTF8.GetBytes(($manifest|ConvertTo-Json -Depth 7 -Compress)) + WriteNewAndFlush (Join-Path $journal 'manifest.json') $data + # Durable READY is the last file created and flushed. The caller must not + # change the live BrokerRoot before this point. + WriteNewAndFlush (Join-Path $journal 'READY') ([Text.Encoding]::ASCII.GetBytes('R306_READY_V1')) + if((SHA (Join-Path $journal 'READY')) -eq 'MISSING'){throw 'R306_READY_NOT_DURABLE'} +} +function RestoreDurable([string]$broker,[string]$journal,[string]$expectedSid){ + if(-not (Test-Path -LiteralPath (Join-Path $journal 'READY') -PathType Leaf)){ + throw 'R306_NOT_READY_NO_RESTORE' + } + Assert-SensorBoundaryNoReparse $journal + $marker=[IO.File]::ReadAllText((Join-Path $journal 'READY'),[Text.Encoding]::ASCII) + if($marker -cne 'R306_READY_V1'){throw 'R306_READY_NOT_MATCHED'} + $manifest=[IO.File]::ReadAllText((Join-Path $journal 'manifest.json'),[Text.Encoding]::UTF8)|ConvertFrom-Json + if($manifest.Schema -ne 1 -or $manifest.Status -cne 'PREPARED' -or + $manifest.Sid -cne $expectedSid){throw 'R306_SNAPSHOT_ACCOUNT_MISMATCH'} + $files=@($manifest.OriginalFiles) + if($files.Count -lt 1 -or $files.Count -gt 128){throw 'R306_INVALID_MANIFEST_COUNT'} + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + # Validate ALL saved contents and paths before writing even the first restored byte. + $validated=[Collections.Generic.List[object]]::new() + foreach($entry in $files){ + $relative=[string]$entry.Rel + SafeRelative $relative + if(-not $seen.Add($relative)){throw 'R306_DUPLICATE_MANIFEST_FILE'} + $path=Join-Path (Join-Path $journal 'files') $relative + Assert-SensorBoundaryNoReparse $path + $payload=[IO.File]::ReadAllBytes($path) + if($payload.Length -ne [int]$entry.Length -or + (HashBytes $payload) -cne [string]$entry.Sha256){ + throw 'R306_BACKUP_HASH_INVALID' + } + $validated.Add([pscustomobject]@{Rel=$relative;Bytes=$payload}) + } + Assert-SensorBoundaryNoReparse (Join-Path $journal 'task.xml') + $xml=[IO.File]::ReadAllBytes((Join-Path $journal 'task.xml')) + if((HashBytes $xml) -cne [string]$manifest.TaskSha256){ + throw 'R306_TASK_XML_HASH_INVALID' + } + # Restore writes here are synthetic; in production they would be after + # stopping only the owned task/process, and would need handle-bound writes. + foreach($entry in $validated){ + $target=Join-Path $broker $entry.Rel + Assert-SensorBoundaryNoReparse $target + [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) + Assert-SensorBoundaryNoReparse $target + [IO.File]::WriteAllBytes($target,[byte[]]$entry.Bytes) + } + foreach($entry in $validated){ + if((SHA (Join-Path $broker $entry.Rel)) -cne (HashBytes $entry.Bytes)){ + throw 'R306_RESTORED_HASH_NOT_EQUAL_TO_MANIFEST' + } + } + return [Text.Encoding]::UTF8.GetString($xml) +} +function StartWorker([string]$mode,[string]$fixture){ + $hostPath=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + if(-not [IO.File]::Exists($hostPath)){throw 'R306_PINNED_NATIVE_POWERSHELL_MISSING'} + $command='-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'" -Mode '+$mode+' -Fixture "'+$fixture+'"' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$hostPath;$psi.Arguments=$command + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) +} +if($Mode -ne 'Driver'){ + if([string]::IsNullOrEmpty($Fixture) -or -not [IO.Directory]::Exists($Fixture)){throw 'R306_FIXTURE_UNKNOWN'} + $broker=Join-Path $Fixture 'broker' + $journal=Join-Path $Fixture 'journal' + $sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + if($Mode -eq 'Worker'){ + SaveDurable $broker $journal $sid 'OWNED_PREVIOUS_SENSOR_SERVICE' + [IO.File]::Delete((Join-Path $broker 'broker.exe')) + [IO.File]::WriteAllText((Join-Path $Fixture 'MUTATED'),'READY_AND_LIVE_FILE_REMOVED') + Start-Sleep -Seconds 35 + exit 0 + } + if($Mode -eq 'Recover'){ + $xml=RestoreDurable $broker $journal $sid + if($xml -cne 'OWNED_PREVIOUS_SENSOR_SERVICE'){ + throw 'R306_TASK_XML_NOT_PRESERVED' + } + [Console]::Write('R306_SYNTHETIC_RESTORED') + exit 0 + } + throw 'R306_MODE_INVALID' +} +$fixture=Join-Path $env:TEMP ('R306_DISPOSABLE_'+[guid]::NewGuid().ToString('N')) +$broker=Join-Path $fixture 'broker' +$outside=Join-Path $fixture 'outside' +[void][IO.Directory]::CreateDirectory($broker) +[void][IO.Directory]::CreateDirectory($outside) +$first=Join-Path $broker 'broker.exe' +$second=Join-Path $broker 'sensor.config' +$canary=Join-Path $outside 'unrelated-user.txt' +[IO.File]::WriteAllBytes($first,([Text.Encoding]::ASCII.GetBytes('ORIGINAL_SIGNED_BROKER_BINARY_R306'))) +[IO.File]::WriteAllBytes($second,([Text.Encoding]::ASCII.GetBytes('ORIGINAL_CONFIGURATION_R306'))) +[IO.File]::WriteAllText($canary,'UNRELATED_USER_DATA_NOT_CHANGED') +$baselineA=SHA $first;$baselineB=SHA $second;$baselineCanary=SHA $canary +$worker=StartWorker Worker $fixture +try{ + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not(Test-Path -LiteralPath (Join-Path $fixture 'MUTATED')) -and + -not $worker.HasExited -and $clock.ElapsedMilliseconds -lt 20000){ + Start-Sleep -Milliseconds 80 + } + if(-not (Test-Path -LiteralPath (Join-Path $fixture 'MUTATED')) -or $worker.HasExited){ + throw ('R306_WORKER_NO_DURABLE_READY_CHECKPOINT '+$worker.StandardError.ReadToEnd()) + } + if((SHA $first) -cne 'MISSING' -or (SHA (Join-Path $fixture 'journal\READY')) -eq 'MISSING'){ + throw 'R306_PROTECTED_CRASH_WINDOW_NOT_REPRODUCED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R306_WORKER_NOT_ACTUALLY_TERMINATED' + } +}finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() +} +$recover=StartWorker Recover $fixture +try{ + if(-not $recover.WaitForExit(16000) -or $recover.ExitCode -ne 0){ + throw ('R306_SEPARATE_PROCESS_RECOVERY_FAILED '+$recover.StandardError.ReadToEnd()) + } +}finally{ + if(-not $recover.HasExited){$recover.Kill();[void]$recover.WaitForExit(9000)} + $recover.Dispose() +} +if((SHA $first) -cne $baselineA -or (SHA $second) -cne $baselineB -or (SHA $canary) -cne $baselineCanary){ + throw 'R306_CROSS_PROCESS_RECOVERY_WRONG' +} +# R306 negative tests: an unrelated SID and an incomplete journal are +# refused before any live write, even when the underlying snapshot is valid. +$foreign='S-1-5-21-12345-23456-34567-45678' +$denied=$false +try{$null=RestoreDurable $broker (Join-Path $fixture 'journal') $foreign} +catch{if($_.Exception.Message -match 'R306_SNAPSHOT_ACCOUNT_MISMATCH'){$denied=$true}else{throw}} +if(-not $denied -or (SHA $first) -cne $baselineA){throw 'R306_FOREIGN_SID_NOT_FAIL_CLOSED'} +$ready=Join-Path $fixture 'journal\READY' +[IO.File]::Delete($ready) +$notReady=$false +try{$null=RestoreDurable $broker (Join-Path $fixture 'journal') ([string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value))} +catch{if($_.Exception.Message -match 'R306_NOT_READY_NO_RESTORE'){$notReady=$true}else{throw}} +if(-not $notReady -or (SHA $first) -cne $baselineA){throw 'R306_INCOMPLETE_BACKUP_NOT_FAIL_CLOSED'} +WriteNewAndFlush $ready ([Text.Encoding]::ASCII.GetBytes('R306_READY_V1')) +if((SHA $canary) -cne $baselineCanary){throw 'R306_OUTSIDE_CANARY_CHANGED'} + +# Tamper one protected snapshot AFTER restoring once. The next attempt must +# refuse before replacing live file: check existing modified test file hash. +[IO.File]::WriteAllBytes($first,[Text.Encoding]::ASCII.GetBytes('CURRENT_CANARY_KEEP')) +$liveBefore=SHA $first +[IO.File]::WriteAllBytes((Join-Path $fixture 'journal\files\broker.exe'), + [Text.Encoding]::ASCII.GetBytes('TAMPERED_SNAPSHOT')) +$failed=$false +try{$null=RestoreDurable $broker (Join-Path $fixture 'journal') ([string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value))} +catch{if($_.Exception.Message -match 'R306_BACKUP_HASH_INVALID'){$failed=$true}else{throw}} +if(-not $failed -or (SHA $first) -cne $liveBefore -or (SHA $canary) -cne $baselineCanary){ + throw 'R306_INVALID_SNAPSHOT_MUST_NOT_MUTATE_LIVE' +} +$receipt=[ordered]@{ + Schema=1;Revision='R306-PROTOTYPE' + Status='PASS_SYNTHETIC_DURABLE_SNAPSHOT_KILL_RESTORE_AND_TAMPER_REFUSAL' + Commit=(git -C $PSScriptRoot rev-parse HEAD) + OriginalProtectedServicesTouched=$false + UACInvoked=$false + ProgramFilesWritten=$false + ActualProofWorkerKilled=$true + SameUserFreshProcessRestore=$true + PreWriteSnapshotHashValidation=$true + CrossSidRefusedBeforeWrite=$true + MissingReadyMarkerRefused=$true + ActualSensorHelperKilled=$false + PreviousTaskXmlTransportedButNotRegistered=$true + ExternalCanaryPreserved=$true + FixtureRoot=$fixture + FixtureFilesSHA256=@{BrokerOriginal=$baselineA;ConfigOriginal=$baselineB;Canary=$baselineCanary} + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +$resultPath=Join-Path $fixture 'R306_RECEIPT.json' +[IO.File]::WriteAllText($resultPath,($receipt|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +"R306_PROTOTYPE_PASS Receipt=$resultPath" diff --git a/build/Test-R307-ProtectedJournalAcl-Native.ps1 b/build/Test-R307-ProtectedJournalAcl-Native.ps1 new file mode 100644 index 00000000..84254153 --- /dev/null +++ b/build/Test-R307-ProtectedJournalAcl-Native.ps1 @@ -0,0 +1,167 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R307_ONLY_DISPOSABLE_NATIVE_WINDOWS' +} +$root=Join-Path $env:RUNNER_TEMP ('R307_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$protected=Join-Path $root 'owned-protected-journal' +$unsafe=Join-Path $root 'unsafe-user-journal' +$outside=Join-Path $root 'unrelated-user-canary' +$receipt=Join-Path $root 'R307_PROTECTED_JOURNAL_ACL_RECEIPT.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R307_PREEXISTING_GUEST_JOURNAL_FIXTURE'} +[void][IO.Directory]::CreateDirectory($root) +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'canary.txt' +[IO.File]::WriteAllText($canary,'R307_UNRELATED_CANARY',[Text.Encoding]::ASCII) +$canarySHA=(Get-FileHash -LiteralPath $canary -Algorithm SHA256).Hash +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$trusted=[Security.Principal.SecurityIdentifier]::new('S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') +$allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +[void]$allowed.Add($admins.Value) +[void]$allowed.Add($system.Value) +[void]$allowed.Add($trusted.Value) +$writable=( + [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Modify -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership +) +function Assert-R307ProtectedAcl([string]$target){ + $info=Get-Item -LiteralPath $target -Force -ErrorAction Stop + if(-not $info.PSIsContainer -or ($info.Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R307_REPARSE_OR_NON_DIRECTORY_REFUSED' + } + $acl=Get-Acl -LiteralPath $target -ErrorAction Stop + $owner=[Security.Principal.NTAccount]::new([string]$acl.Owner) + $ownerSid=$owner.Translate([Security.Principal.SecurityIdentifier]) + if(-not $allowed.Contains($ownerSid.Value)){ + throw ('R307_JOURNAL_OWNER_NOT_TRUSTED '+$ownerSid.Value) + } + if(-not $acl.AreAccessRulesProtected){ + throw 'R307_JOURNAL_INHERITED_DACL_REFUSED' + } + $aces=$acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) + foreach($rule in $aces){ + if($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + -not $allowed.Contains([string]$rule.IdentityReference.Value) -and + ($rule.FileSystemRights -band $writable)){ + throw 'R307_JOURNAL_BROAD_WRITE_ALLOW_REFUSED' + } + } + return $acl +} +$security=[Security.AccessControl.DirectorySecurity]::new() +$security.SetOwner($admins) +$security.SetAccessRuleProtection($true,$false) +$inherit=[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' +$none=[Security.AccessControl.PropagationFlags]::None +$allow=[Security.AccessControl.AccessControlType]::Allow +foreach($principal in @($admins,$system)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $principal,[Security.AccessControl.FileSystemRights]::FullControl, + $inherit,$none,$allow) + $security.AddAccessRule($rule) +} +# .NET Framework Windows PowerShell 5.1 supports atomic directory creation +# with an explicit DACL. Never create first and tighten permissions later. +[void][IO.Directory]::CreateDirectory($protected,$security) +$effective=Assert-R307ProtectedAcl $protected +$checks.Add('CREATED_WITH_TRUSTED_OWNER_AND_PROTECTED_DACL') +$aclSids=@($effective.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])| + ForEach-Object {$_.IdentityReference.Value}) +if(-not ($aclSids -contains 'S-1-5-32-544') -or + -not ($aclSids -contains 'S-1-5-18')){ + throw 'R307_SYSTEM_AND_ADMIN_FULL_CONTROL_MISSING' +} +$checks.Add('SYSTEM_AND_ADMINISTRATORS_PERSISTENT_ACCESS') +$file=Join-Path $protected 'manifest.json' +$bytes=[Text.Encoding]::UTF8.GetBytes('{"Schema":1,"Sid":"R307"}') +$stream=[IO.FileStream]::new($file,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) +try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} +if((Get-FileHash $file -Algorithm SHA256).Hash -cne + ([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash($bytes))).Replace('-',''))){ + throw 'R307_PROTECTED_MANIFEST_FLUSH_READBACK_MISMATCH' +} +$checks.Add('FLUSHED_MANIFEST_SHA_READBACK') +function Assert-R307ProtectedManifestFile([string]$path){ + $fileInfo=Get-Item -LiteralPath $path -Force -ErrorAction Stop + if($fileInfo.PSIsContainer -or ($fileInfo.Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R307_MANIFEST_NOT_ORDINARY_FILE' + } + $acl=Get-Acl -LiteralPath $path -ErrorAction Stop + $ownerSid=([Security.Principal.NTAccount]::new([string]$acl.Owner)).Translate( + [Security.Principal.SecurityIdentifier]) + if(-not $allowed.Contains($ownerSid.Value)){throw 'R307_MANIFEST_OWNER_NOT_TRUSTED'} + foreach($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){ + if($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + -not $allowed.Contains([string]$rule.IdentityReference.Value) -and + ($rule.FileSystemRights -band $writable)){ + throw 'R307_MANIFEST_FILE_BROAD_WRITE_ALLOWED' + } + } +} +Assert-R307ProtectedManifestFile $file +$checks.Add('MANIFEST_FILE_OWNER_AND_ACL_TRUSTED') +# A preexisting user-writable directory must never be accepted as a journal. +[void][IO.Directory]::CreateDirectory($unsafe) +$denied=$false +try{$null=Assert-R307ProtectedAcl $unsafe} +catch{if($_.Exception.Message -match 'OWNER_NOT_TRUSTED|INHERITED_DACL_REFUSED|BROAD_WRITE_ALLOW_REFUSED'){$denied=$true}else{throw}} +if(!$denied){throw 'R307_EXISTING_USER_WRITABLE_FOLDER_ACCEPTED'} +$checks.Add('USER_WRITABLE_EXISTING_DIRECTORY_FAIL_CLOSED') +# A junction, even if placed under a legitimate parent, is never accepted. +$link=Join-Path $root 'test-junction' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $link -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R307_NATIVE_JUNCTION_NOT_CREATED' +} +$denied=$false +try{$null=Assert-R307ProtectedAcl $link}catch{ + if($_.Exception.Message -match 'REPARSE_OR_NON_DIRECTORY_REFUSED'){$denied=$true}else{throw} +} +if(!$denied){throw 'R307_JUNCTION_TRAVERSED'} +if((Get-FileHash $canary -Algorithm SHA256).Hash -cne $canarySHA){ + throw 'R307_JUNCTION_DESTINATION_CHANGED' +} +$checks.Add('REAL_REPARSE_JUNCTION_REFUSED_AND_CANARY_IMMUTABLE') +# A read-only observation of real Program Files ancestors is informative, +# but never mutates live protected sensor files or ACLs. +$programFiles=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$actualProgramFilesOwner=(Get-Acl -LiteralPath $programFiles).Owner +if([string]::IsNullOrWhiteSpace($actualProgramFilesOwner)){throw 'R307_REAL_PROGRAM_FILES_OWNER_UNREADABLE'} +$checks.Add('REAL_PROGRAM_FILES_ACL_READ_ONLY') +$data=[ordered]@{ + Schema=1;Revision='R307-PROTOTYPE' + Status='PASS_PROTECTED_JOURNAL_ACL_TRUST_BOUNDARY_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerTouched=$false + ActualProtectedSensorTaskTouched=$false + RealProgramFilesWritten=$false + ExplicitProtectedDaclCreated=$true + OwnerTrustedAdministrators=$true + SystemAdminAccessVerified=$true + ManifestFileOwnerTrusted=$true + UserWritableRootRejected=$true + NativeJunctionRejected=$true + FlushedManifestHashMatched=$true + OutsideCanaryPreserved=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R307_'+$HostLabel+'_PROTECTED_ACL_MODEL_PASS' diff --git a/build/Test-R308-ProtectedPrewrite-Native.ps1 b/build/Test-R308-ProtectedPrewrite-Native.ps1 new file mode 100644 index 00000000..a7a1c709 --- /dev/null +++ b/build/Test-R308-ProtectedPrewrite-Native.ps1 @@ -0,0 +1,207 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R308_DISPOSABLE_WINDOWS_ONLY' +} +function FileHash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Refused([string]$name,[scriptblock]$test,[string]$pattern){ + $caught=$false + try{& $test}catch{ + if($_.Exception.ToString() -match $pattern){$caught=$true}else{throw} + } + if(!$caught){throw ('R308_TEST_FAILED_OPEN_'+$name)} + $script:checks.Add($name) +} +$dir=Join-Path $env:RUNNER_TEMP ('R308_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $dir 'R308_JOURNAL_PREWRITE_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R307_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R307_PROTECTED_JOURNAL_ACL_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $dir){throw 'R308_GUEST_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($dir) +$live=@{Main=(FileHash $main);Config=(FileHash $config);State=(FileHash $state)} +$source=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($source.Commit -cne $env:GITHUB_SHA -or $source.SetupSHA256 -cne (FileHash $setup) -or + $source.MainSHA256 -cne $live.Main -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_PROTECTED_JOURNAL_ACL_TRUST_BOUNDARY_SYNTHETIC'){ + throw 'R308_CANDIDATE_OR_R307_EVIDENCE_NOT_EXACT' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R308_PE_EMBEDDED_HELPER_MISSING'} +$reader=[IO.StreamReader]::new($stream) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($helper) +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize $raw)){ + throw 'R308_SOURCE_DIFFERS_FROM_COMPILED_SETUP_PE' +} +$checks.Add('SAME_EXACT_PE_EMBEDDED_HELPER_SOURCE') +$save=$raw.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid ',[StringComparison]::Ordinal) +$mutation=$raw.IndexOf(' Assert-OrRecordSensorOwner',[StringComparison]::Ordinal) +$ownerBoundary=$raw.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) +$legacyStale=$raw.IndexOf('if(Test-Path -LiteralPath $SensorJournalRoot)',[StringComparison]::Ordinal) +$protectedPreflight=[Math]::Max( + $raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal), + $raw.IndexOf('Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName',[StringComparison]::Ordinal)) +if($save -lt 0 -or $mutation -le $save -or $ownerBoundary -lt 0 -or + ($legacyStale -lt 0 -and $protectedPreflight -lt 0) -or + ($protectedPreflight -ge 0 -and + ($protectedPreflight -le $ownerBoundary -or $protectedPreflight -ge $save))){ + throw 'R308_JOURNAL_NOT_BEFORE_FIRST_PROTECTED_MUTATION' +} +$checks.Add('STALE_JOURNAL_REFUSAL_AND_PREMUTATION_ORDER') +$tok=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tok,[ref]$errors) +if(@($errors).Count){throw 'R308_POWERSHELL51_PARSER_REJECTED_HELPER'} +$names=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl', + 'Write-SensorJournalUnique','Assert-SensorDurableJournal','New-SensorDurableJournal') +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names +},$true)) +if($defs.Count -ne 7){throw 'R308_EXACT_SEVEN_FUNCTIONS_MISSING'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('COMPILED_PRODUCTION_FUNCTIONS_LOADED_NATIVE_PS51') +$admin=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admin);$acl.SetAccessRuleProtection($true,$false) +foreach($sid in @($admin,$system)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $sid,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) +} +$parent=Join-Path $dir 'trusted-synthetic-parent' +[void][IO.Directory]::CreateDirectory($parent,$acl) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('OWNER_AND_EXPLICIT_PROTECTED_PARENT_DACL') +$broker=Join-Path $dir 'synthetic-broker' +[void][IO.Directory]::CreateDirectory($broker) +$oldFile=Join-Path $broker 'TaskbarMonitorSensorBroker.exe' +$oldConfig=Join-Path $broker 'lang\config.bin' +[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($oldConfig)) +[IO.File]::WriteAllBytes($oldFile,[Text.Encoding]::ASCII.GetBytes('R308_KNOWN_OLD_BROKER')) +[IO.File]::WriteAllBytes($oldConfig,[Text.Encoding]::ASCII.GetBytes('R308_KNOWN_OLD_CONFIG')) +$originalBroker=FileHash $oldFile;$originalConfig=FileHash $oldConfig +$outside=Join-Path $dir 'external' +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'preserve.txt' +[IO.File]::WriteAllText($canary,'R308_OUTSIDE_CANARY',[Text.Encoding]::ASCII) +$canaryHash=FileHash $canary +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($oldFile)}, + [pscustomobject]@{Rel='lang\config.bin';Data=[IO.File]::ReadAllBytes($oldConfig)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','.tbme-owner.sid','lang\config.bin') +$identity=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($identity -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R308_GUEST_NOT_INTERACTIVE_USER'} +$xml='R308_ORIGINAL_SYNTHETIC_TASK' +$journal=Join-Path $parent '.tbme-sensor-rollback' +New-SensorDurableJournal $journal $identity $rows $xml $next $true +if(!(Test-Path (Join-Path $journal 'READY')) -or + (FileHash $oldFile) -cne $originalBroker -or (FileHash $oldConfig) -cne $originalConfig){ + throw 'R308_DURABLE_READY_NOT_PREMUTATION' +} +$validated=Assert-SensorDurableJournal $journal $identity +if(@($validated.Files).Count -ne 2 -or @($validated.ExpectedNewFiles).Count -ne 4 -or + $validated.TaskPresent -ne $true -or $validated.OldRootPresent -ne $true){ + throw 'R308_SERIALIZED_DURABLE_MANIFEST_INVALID' +} +$checks.Add('DURABLE_READY_AND_MANIFEST_OLD_BYTES_XML_VERIFIED') +Refused 'DUPLICATE_JOURNAL_REFUSED' { + New-SensorDurableJournal $journal $identity $rows $xml $next $true +} 'SENSOR_JOURNAL_ALREADY_EXISTS' +$foreign='S-1-5-21-111-222-333-444' +Refused 'FOREIGN_EFFECTIVE_SID_REFUSED_PREWRITE' { + New-SensorDurableJournal (Join-Path $parent 'other-sid') $foreign $rows $xml $next $true +} 'SENSOR_JOURNAL_TOKEN_MISMATCH' +if(Test-Path (Join-Path $parent 'other-sid')){throw 'R308_FOREIGN_SID_CREATED_JOURNAL'} +Refused 'SAVED_JOURNAL_FOREIGN_SID_REFUSED' { + [void](Assert-SensorDurableJournal $journal $foreign) +} 'SENSOR_JOURNAL_OWNER_OR_PHASE_MISMATCH' +$taskFile=Join-Path $journal 'task.xml' +$originalTask=[IO.File]::ReadAllBytes($taskFile) +[IO.File]::WriteAllText($taskFile,'R308_FAKE_XML',[Text.Encoding]::ASCII) +Refused 'TASK_XML_TAMPER_DETECTED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' +[IO.File]::WriteAllBytes($taskFile,$originalTask) +$saved=Join-Path $journal 'files\TaskbarMonitorSensorBroker.exe' +$originalSaved=[IO.File]::ReadAllBytes($saved) +[IO.File]::WriteAllText($saved,'R308_TAMPERED',[Text.Encoding]::ASCII) +Refused 'SNAPSHOT_BYTE_TAMPER_DETECTED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_SNAPSHOT_HASH_MISMATCH' +[IO.File]::WriteAllBytes($saved,$originalSaved) +$unexpected=Join-Path $journal 'unexpected.txt' +[IO.File]::WriteAllText($unexpected,'R308_UNEXPECTED',[Text.Encoding]::ASCII) +Refused 'UNKNOWN_MEMBER_REJECTED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_UNKNOWN_MEMBER' +[IO.File]::Delete($unexpected) +$link=Join-Path $journal 'junction' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +Refused 'NATIVE_JUNCTION_NOT_FOLLOWED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_REPARSE_MEMBER' +[IO.Directory]::Delete($link) +$incomplete=Join-Path $parent 'incomplete' +[void][IO.Directory]::CreateDirectory($incomplete,$acl) +Refused 'INCOMPLETE_READY_REFUSED' { + [void](Assert-SensorDurableJournal $incomplete $identity) +} 'SENSOR_JOURNAL_INCOMPLETE_OR_UNREADY' +$unsafe=Join-Path $dir 'user-writable-parent' +[void][IO.Directory]::CreateDirectory($unsafe) +Refused 'WRITABLE_PARENT_REFUSED_BEFORE_CREATE' { + New-SensorDurableJournal (Join-Path $unsafe '.tbme-sensor-rollback') $identity $rows $xml $next $true +} 'SENSOR_JOURNAL_UNTRUSTED_OWNER|SENSOR_JOURNAL_UNTRUSTED_WRITE_ACE' +if(Test-Path (Join-Path $unsafe '.tbme-sensor-rollback')){ + throw 'R308_INSECURE_PARENT_JOURNAL_CREATED' +} +if((FileHash $oldFile) -cne $originalBroker -or (FileHash $oldConfig) -cne $originalConfig -or + (FileHash $canary) -cne $canaryHash -or + (FileHash $main) -cne $live.Main -or (FileHash $config) -cne $live.Config -or + (FileHash $state) -cne $live.State){throw 'R308_INSTALLED_OR_SYNTHETIC_LIVE_DATA_CHANGED'} +$checks.Add('MAIN_CONFIG_STATE_AND_EXTERNAL_CANARY_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R308';Status='PASS_PRODUCTION_JOURNAL_PREWRITE_FUNCTIONS_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerTouched=$false;ActualProgramFilesWritten=$false + ActualScheduledTaskModified=$false;ActualUACInvoked=$false + ActualProtectedSensorHelperKilled=$false + ProductionFunctionsExtractedFromExactPE=$true + JournalReadyPublishedPreMutation=$true + StaleJournalFailsClosed=$true + SIDAndHashAndReparseFailClosed=$true + ExternalUserDataUnchanged=$true + TerminalCleanupAndColdRecoveryImplemented=$false + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R308_'+$HostLabel+'_PRODUCTION_JOURNAL_PREWRITE_SYNTHETIC_PASS' diff --git a/build/Test-R309-TerminalReplay-Native.ps1 b/build/Test-R309-TerminalReplay-Native.ps1 new file mode 100644 index 00000000..13e3d56a --- /dev/null +++ b/build/Test-R309-TerminalReplay-Native.ps1 @@ -0,0 +1,279 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R309_ONLY_DISPOSABLE_HOSTED_WINDOWS' +} +function SHA([string]$f){ + if(-not [IO.File]::Exists($f)){return 'MISSING'} + return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash +} +function AssertFails([string]$key,[scriptblock]$probe,[string]$pattern){ + $rejected=$false + try{& $probe}catch{ + if($_.Exception.ToString() -match $pattern){$rejected=$true}else{throw} + } + if(-not $rejected){throw ('R309_EXPECTED_REFUSAL_NOT_OBSERVED_'+$key)} + $script:checks.Add($key) +} +$root=Join-Path $env:RUNNER_TEMP ('R309_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R309_TERMINAL_REPLAY_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R308_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R308_JOURNAL_PREWRITE_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +# Load ONLY extracted PE-source security functions. Never invoke the actual +# elevated Helper entrypoint, UAC, any task or real ProgramFiles files. +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R309_COMPILED_PE_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$src=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $src) -cne (& $normalize ([IO.File]::ReadAllText($helper)))){ + throw 'R309_PE_HELPER_SOURCE_NOT_EXACT' +} +$t=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$errors) +if(@($errors).Count){throw 'R309_SOURCE_WINPS51_PARSER_FAILURE'} +$names=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl', + 'Write-SensorJournalUnique','Assert-SensorDurableJournal','New-SensorDurableJournal', + 'Get-SensorJournalTerminal','Write-SensorJournalTerminal','Restore-SensorJournalOldFilesOnly') +$found=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in $names +},$true)) +if($found.Count -ne $names.Count){throw 'R309_EXACT_PE_PRODUCTION_REPLAY_FUNCTIONS_MISSING'} +. ([scriptblock]::Create(($found|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$identity=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($identity -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R309_NONINTERACTIVE_RUNNER_SID'} +if($Mode -ne 'Driver'){ + if([IO.Path]::GetFullPath($Fixture) -cne [IO.Path]::GetFullPath($root) -or + -not [IO.Directory]::Exists($root)){throw 'R309_UNTRUSTED_PROOF_WORKER_FIXTURE'} + $live=Join-Path $root 'live-broker' + $journal=Join-Path (Join-Path $root 'protected-parent') 'stage' + $old=Join-Path $live 'TaskbarMonitorSensorBroker.exe' + $newFile=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' + if($Mode -eq 'Worker'){ + [IO.File]::WriteAllBytes($old,[Text.Encoding]::ASCII.GetBytes('R309_NEW_DIFFERENT_BROKER')) + [IO.File]::WriteAllBytes($newFile,[Text.Encoding]::ASCII.GetBytes('R309_NEW_SUPERVISOR')) + [IO.File]::WriteAllText((Join-Path $root 'WORKER_CHECKPOINT'),'LIVE_MUTATED_AFTER_READY',[Text.Encoding]::ASCII) + Start-Sleep -Seconds 28 + exit 0 + } + if($Mode -eq 'Recover'){ + $xml=Restore-SensorJournalOldFilesOnly $journal $identity $live + if($xml -cne 'R309_OLD_TASK_XML'){throw 'R309_RECOVERY_TASK_XML_NOT_RESTORED'} + Write-SensorJournalTerminal $journal $identity 'ROLLED_BACK' + [IO.File]::WriteAllText((Join-Path $root 'RECOVERED'),'COLD_PROOF_WORKER_COMPLETED',[Text.Encoding]::ASCII) + exit 0 + } +} +if(Test-Path -LiteralPath $root){throw 'R309_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($root) +$before=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $before.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_PRODUCTION_JOURNAL_PREWRITE_FUNCTIONS_SYNTHETIC'){ + throw 'R309_EXACT_SOURCE_OR_PREVIOUS_WITNESS_MISMATCH' +} +$checks.Add('EXACT_PE_HELPER_AND_PREDECESSOR_SOURCE_BOUND') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$sys=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$sec=[Security.AccessControl.DirectorySecurity]::new() +$sec.SetOwner($admins) +$sec.SetAccessRuleProtection($true,$false) +foreach($id in @($admins,$sys)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new($id, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $sec.AddAccessRule($rule) +} +$parent=Join-Path $root 'protected-parent' +[void][IO.Directory]::CreateDirectory($parent,$sec) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('PRIVATE_TRUSTED_DACL_WITH_SYSTEM_ADMIN_OWNERSHIP') +$live=Join-Path $root 'live-broker' +[void][IO.Directory]::CreateDirectory($live) +$old=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$cfg=Join-Path $live 'settings\sensor.bin' +[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($cfg)) +[IO.File]::WriteAllBytes($old,[Text.Encoding]::ASCII.GetBytes('R309_ORIGINAL_BROKER')) +[IO.File]::WriteAllBytes($cfg,[Text.Encoding]::ASCII.GetBytes('R309_ORIGINAL_CONFIG')) +$oldHash=SHA $old;$configHash=SHA $cfg +$external=Join-Path $root 'unrelated' +[void][IO.Directory]::CreateDirectory($external) +$canary=Join-Path $external 'canary.txt' +[IO.File]::WriteAllText($canary,'R309_KEEP_OUTSIDE',[Text.Encoding]::ASCII) +$externalHash=SHA $canary +$journal=Join-Path $parent 'stage' +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($old)}, + [pscustomobject]@{Rel='settings\sensor.bin';Data=[IO.File]::ReadAllBytes($cfg)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\sensor.bin') +New-SensorDurableJournal $journal $identity $rows 'R309_OLD_TASK_XML' $next $true +if((Get-SensorJournalTerminal $journal $identity) -cne 'PREPARED'){throw 'R309_NEW_JOURNAL_NOT_PREPARED'} +$checks.Add('READY_PUBLISHED_AND_PREPARED_STATE_VALIDATED') +function StartProof([string]$mode){ + $exe=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$exe + $psi.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+ + '" -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$root+'"') + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) +} +$worker=StartProof Worker +if(!$worker){throw 'R309_SYNTHETIC_MUTATOR_NOT_STARTED'} +$workerPid=$worker.Id +$timer=[Diagnostics.Stopwatch]::StartNew() +try{ + while(-not [IO.File]::Exists((Join-Path $root 'WORKER_CHECKPOINT')) -and + -not $worker.HasExited -and $timer.ElapsedMilliseconds -lt 17000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $root 'WORKER_CHECKPOINT')) -or + $worker.HasExited){ + throw ('R309_WORKER_NEVER_REACHED_MUTATION '+$worker.StandardError.ReadToEnd()) + } + if((SHA $old) -ceq $oldHash -or + (Get-SensorJournalTerminal $journal $identity) -cne 'PREPARED'){ + throw 'R309_MUTATION_BEFORE_REAL_WORKER_DEATH_MISSING' + } + $worker.Kill() + if(-not $worker.WaitForExit(10000) -or $worker.ExitCode -eq 0){ + throw 'R309_ACTUAL_SYNTHETIC_WORKER_KILL_NOT_CONFIRMED' + } + $checks.Add('REAL_SEPARATE_PROOF_WORKER_KILLED_AFTER_LIVE_MUTATION') +}finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(10000)} + $worker.Dispose() +} +$recover=StartProof Recover +if(!$recover){throw 'R309_RECOVERY_PROCESS_NOT_CREATED'} +$recoveryPid=$recover.Id +try{ + if(-not $recover.WaitForExit(20000) -or $recover.ExitCode -ne 0){ + $err=if($recover.HasExited){$recover.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($err.Length -gt 1500){$err=$err.Substring(0,1500)} + throw ('R309_NATIVE_FRESH_RECOVERY_FAILED '+$err) + } +}finally{ + if(-not $recover.HasExited){$recover.Kill();[void]$recover.WaitForExit(10000)} + $recover.Dispose() +} +if(-not [IO.File]::Exists((Join-Path $root 'RECOVERED')) -or + $workerPid -eq $recoveryPid -or (SHA $old) -cne $oldHash -or + (SHA $cfg) -cne $configHash -or + [IO.File]::Exists((Join-Path $live 'TaskbarMonitorSensorSupervisor.exe')) -or + (Get-SensorJournalTerminal $journal $identity) -cne 'ROLLED_BACK'){ + throw 'R309_COLD_WORKER_RECOVERY_DATA_OR_STATE_FAILED' +} +$checks.Add('FRESH_SEPARATE_PROCESS_RESTORES_EXACT_OLD_BYTES') +$checks.Add('ROLLED_BACK_TERMINAL_MARKER_DURABLE') +$xml=Restore-SensorJournalOldFilesOnly $journal $identity $live +if($xml -cne 'R309_OLD_TASK_XML'){throw 'R309_REPEAT_RECOVERY_TASK_XML_WRONG'} +Write-SensorJournalTerminal $journal $identity 'ROLLED_BACK' +$checks.Add('REPEAT_RECOVERY_IDEMPOTENT_AFTER_TERMINAL') +AssertFails 'CONFLICTING_COMMIT_MARKER_REJECTED' { + Write-SensorJournalTerminal $journal $identity 'COMMITTED' +} 'SENSOR_JOURNAL_TERMINAL_CONFLICT' +# Reject tainted old snapshot before any live byte is replaced. +$sourceBlob=Join-Path $journal 'files\TaskbarMonitorSensorBroker.exe' +$trusted=[IO.File]::ReadAllBytes($sourceBlob) +[IO.File]::WriteAllBytes($old,[Text.Encoding]::ASCII.GetBytes('R309_LIVE_PRESERVE_ON_ERROR')) +$beforeRefuse=SHA $old +[IO.File]::WriteAllBytes($sourceBlob,[Text.Encoding]::ASCII.GetBytes('R309_TAMPERED_BACKUP')) +AssertFails 'INVALID_SNAPSHOT_PREWRITE_REFUSED' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_SNAPSHOT_HASH_MISMATCH|SENSOR_JOURNAL_REPLAY_SHA_MISMATCH' +if((SHA $old) -cne $beforeRefuse){throw 'R309_TAMPERED_BACKUP_CHANGED_LIVE_FILE'} +[IO.File]::WriteAllBytes($sourceBlob,$trusted) +# Unknown live member must block any attempted restore. +$extra=Join-Path $live 'unauthorized-user.txt' +[IO.File]::WriteAllText($extra,'UNAUTHORIZED',[Text.Encoding]::ASCII) +AssertFails 'UNKNOWN_LIVE_MEMBER_REFUSED_PREWRITE' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' +if((SHA $old) -cne $beforeRefuse){throw 'R309_UNKNOWN_LIVE_MEMBER_CHANGED_LIVE_FILE'} +[IO.File]::Delete($extra) +$junction=Join-Path $live 'rogue-junction' +New-Item -ItemType Junction -Path $junction -Target $external -ErrorAction Stop|Out-Null +AssertFails 'LIVE_JUNCTION_REFUSED_BEFORE_REPLAY' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_REPLAY_LIVE_REPARSE_REFUSED' +[IO.Directory]::Delete($junction) +$taskPath=Join-Path $journal 'task.xml' +$taskOriginal=[IO.File]::ReadAllBytes($taskPath) +[IO.File]::WriteAllText($taskPath,'R309_TAMPER',[Text.Encoding]::ASCII) +AssertFails 'SAVED_TASK_XML_TAMPER_REFUSED' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' +[IO.File]::WriteAllBytes($taskPath,$taskOriginal) +AssertFails 'CROSS_USER_SID_REPLAY_REFUSED' { + [void](Restore-SensorJournalOldFilesOnly $journal 'S-1-5-21-111-222-333-444' $live) +} 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' +# COMMITTED is terminal and never authorizes rollback. +$other=Join-Path $parent 'committed' +New-SensorDurableJournal $other $identity $rows 'R309_OLD_TASK_XML' $next $true +Write-SensorJournalTerminal $other $identity 'COMMITTED' +if((Get-SensorJournalTerminal $other $identity) -cne 'COMMITTED'){ + throw 'R309_COMMITTED_MARKER_NOT_READ_BACK' +} +AssertFails 'COMMITTED_JOURNAL_CANNOT_REVERT_LIVE_LAYER' { + [void](Restore-SensorJournalOldFilesOnly $other $identity $live) +} 'SENSOR_JOURNAL_REPLAY_REQUIRES_PREPARED_STATE' +$checks.Add('COMMITTED_TERMINAL_READBACK_PASS') +# End-of-test invariants: no user/system service/registry mutation. +if((SHA $canary) -cne $externalHash -or + (SHA $main) -cne $before.Main -or + (SHA $config) -cne $before.Config -or + (SHA $state) -cne $before.State){ + throw 'R309_GUEST_OWNER_OR_UNRELATED_CANARY_CHANGED' +} +$checks.Add('GUEST_INSTALLED_USER_AND_UNRELATED_BYTES_IMMUTABLE') +$result=[ordered]@{ + Schema=1;Revision='R309' + Status='PASS_SYNTHETIC_TERMINAL_AND_REAL_WORKER_COLD_FILE_REPLAY' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + ActualOriginalElevatedHelperKilled=$false;ActualProtectedTaskTouched=$false + RealProgramFilesWritten=$false;UACInvoked=$false;OwnerComputerTouched=$false + ActualSyntheticWorkerKilled=$true;FreshPowerShellRecoveredOldFiles=$true + ProductionPEReplayFunctionsVerified=$true + TerminalCommittedAndRolledBackVerified=$true + PinnedTaskXmlRecoveredAsDataNotRegistered=$true + TamperAndForeignSidFailedClosed=$true + UnknownFileAndJunctionFailedClosed=$true + OfficialInstalledMainAndUserConfigUnchanged=$true + WorkerPid=$workerPid;RecoveryPid=$recoveryPid + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R309_NATIVE_'+$HostLabel+'_TERMINAL_AND_COLD_FILE_REPLAY_PASS' diff --git a/build/Test-R310-RetiredJournal-Native.ps1 b/build/Test-R310-RetiredJournal-Native.ps1 new file mode 100644 index 00000000..ece15b5c --- /dev/null +++ b/build/Test-R310-RetiredJournal-Native.ps1 @@ -0,0 +1,258 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){throw 'R310_DISPOSABLE_WINDOWS_ONLY'} +function SHA([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +function Reject([string]$label,[scriptblock]$probe,[string]$pattern){ + $denied=$false + try{& $probe}catch{ + if($_.Exception.ToString() -match $pattern){$denied=$true}else{throw} + } + if(!$denied){throw ('R310_EXPECTED_NEGATIVE_NOT_REFUSED_'+$label)} + $script:checks.Add($label) +} +$base=Join-Path $env:RUNNER_TEMP ('R310_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $base 'R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R309_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R309_TERMINAL_REPLAY_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R310_SETUP_PE_EMBEDDED_HELPER_MISSING'} +$reader=[IO.StreamReader]::new($stream) +try{$code=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $code) -cne (& $normal ([IO.File]::ReadAllText($helper)))){ + throw 'R310_PRIVATE_PE_HELPER_SOURCE_IDENTITY_WRONG' +} +$t=$null;$err=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$err) +if(@($err).Count){throw 'R310_COMPILED_WINPS51_HELPER_PARSE_FAILED'} +$wanted=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique', + 'Assert-SensorDurableJournal','New-SensorDurableJournal','Get-SensorJournalTerminal', + 'Write-SensorJournalTerminal','Restore-SensorJournalOldFilesOnly', + 'Assert-SensorRetiredJournalSafe','Finish-SensorRetiredJournal', + 'Finish-SensorTerminalJournal') +$funcs=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -in $wanted +},$true)) +if($funcs.Count -ne 13){throw 'R310_PE_EXACT_13_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($funcs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R310_GUEST_ACCOUNT_NOT_INTERACTIVE'} +if($Mode -ne 'Driver'){ + if(-not [string]::Equals([IO.Path]::GetFullPath($Fixture), + [IO.Path]::GetFullPath($base),[StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Directory]::Exists($base)){throw 'R310_UNKNOWN_CHILD_FIXTURE'} + $root=Join-Path (Join-Path $base 'protected') 'stage' + if($Mode -eq 'Worker'){ + if((Get-SensorJournalTerminal $root $sid) -cne 'COMMITTED'){ + throw 'R310_CHILD_SOURCE_NOT_COMMITTED' + } + [IO.Directory]::Move($root,$root+'-retired') + $part=Join-Path ($root+'-retired') 'files\TaskbarMonitorSensorBroker.exe' + if(-not [IO.File]::Exists($part)){throw 'R310_CHILD_SNAPSHOT_MISSING'} + [IO.File]::Delete($part) + [IO.File]::WriteAllText((Join-Path $base 'INTERRUPTION_CHECKPOINT'),'RETIRED_AFTER_FIRST_DELETE') + Start-Sleep -Seconds 30 + exit 0 + } + if($Mode -eq 'Recover'){ + Finish-SensorTerminalJournal $root $sid + [IO.File]::WriteAllText((Join-Path $base 'CLEANUP_RECOVERED'),'RETIRED_JOURNAL_CLEAN') + exit 0 + } +} +if([IO.Directory]::Exists($base)){throw 'R310_EXISTING_GUEST_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($base) +$oldHashes=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $oldHashes.Main -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_SYNTHETIC_TERMINAL_AND_REAL_WORKER_COLD_FILE_REPLAY'){ + throw 'R310_EXACT_PREDECESSOR_OR_CANDIDATE_UNVERIFIED' +} +$checks.Add('COMPILED_PE_AND_PREDECESSOR_EXACT_SOURCE') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admins) +$acl.SetAccessRuleProtection($true,$false) +foreach($id in @($admins,$system)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $id,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) +} +$parent=Join-Path $base 'protected' +[void][IO.Directory]::CreateDirectory($parent,$acl) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('TRUSTED_PROTECTED_PARENT_ACL') +$root=Join-Path $parent 'stage' +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[Text.Encoding]::ASCII.GetBytes('R310_PRIOR_BROKER')}, + [pscustomobject]@{Rel='settings\old.config';Data=[Text.Encoding]::ASCII.GetBytes('R310_PRIOR_CONFIG')} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\old.config') +$xml='R310_PRIOR_TASK' +New-SensorDurableJournal $root $sid $rows $xml $next $true +Write-SensorJournalTerminal $root $sid 'COMMITTED' +if((Get-SensorJournalTerminal $root $sid) -cne 'COMMITTED'){throw 'R310_NO_TERMINAL_STATE'} +$checks.Add('COMMITTED_TERMINAL_PUBLISHED_AND_PINNED') +function Child([string]$mode){ + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=$native + $start.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'"'+ + ' -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$base+'"') + $start.UseShellExecute=$false;$start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + return [Diagnostics.Process]::Start($start) +} +$worker=Child Worker +if(!$worker){throw 'R310_WORKER_CREATE_FAILED'} +$workerPid=$worker.Id +$timer=[Diagnostics.Stopwatch]::StartNew() +try{ + while(-not [IO.File]::Exists((Join-Path $base 'INTERRUPTION_CHECKPOINT')) -and + -not $worker.HasExited -and $timer.ElapsedMilliseconds -lt 18000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $base 'INTERRUPTION_CHECKPOINT')) -or + $worker.HasExited){ + throw ('R310_CHILD_CHECKPOINT_FAILED '+$worker.StandardError.ReadToEnd()) + } + if([IO.Directory]::Exists($root) -or + -not [IO.Directory]::Exists($root+'-retired')){ + throw 'R310_ATOMIC_RENAME_NOT_OBSERVED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R310_REAL_TEST_WORKER_DEATH_NOT_PROVEN' + } + $checks.Add('REAL_WORKER_KILLED_INSIDE_PARTIAL_RETIRED_CLEANUP') +}finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() +} +$restart=Child Recover +if(!$restart){throw 'R310_COLD_RECOVERY_CHILD_NOT_STARTED'} +$restartPid=$restart.Id +try{ + if(-not $restart.WaitForExit(20000) -or $restart.ExitCode -ne 0){ + $errorText=if($restart.HasExited){$restart.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($errorText.Length -gt 1600){$errorText=$errorText.Substring(0,1600)} + throw ('R310_COLD_CLEANUP_FAILED '+$errorText) + } +}finally{ + if(-not $restart.HasExited){$restart.Kill();[void]$restart.WaitForExit(9000)} + $restart.Dispose() +} +if(-not [IO.File]::Exists((Join-Path $base 'CLEANUP_RECOVERED')) -or + [IO.Directory]::Exists($root) -or [IO.Directory]::Exists($root+'-retired') -or + $workerPid -eq $restartPid){throw 'R310_COLD_REPLAY_DID_NOT_RESTORE_EMPTY_JOURNAL'} +$checks.Add('SECOND_PROCESS_CLEARED_PARTIAL_TERMINAL_JOURNAL') +Finish-SensorTerminalJournal $root $sid +$checks.Add('EMPTY_COMPLETED_JOURNAL_CLEANUP_IDEMPOTENT') +# Negative: PREPARED journal may never be retired before rollback. +New-SensorDurableJournal $root $sid $rows $xml $next $true +Reject 'UNTERMINATED_PREPARED_JOURNAL_REFUSED' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_JOURNAL_NOT_TERMINAL_DO_NOT_RETIRE' +if(-not [IO.Directory]::Exists($root)){throw 'R310_UNTERMINATED_JOURNAL_REMOVED'} +Write-SensorJournalTerminal $root $sid 'ROLLED_BACK' +$checks.Add('ROLLED_BACK_TERMINAL_IS_VALID_CLEANUP_TARGET') +# Unknown entry must fail BEFORE removing any existing snapshot. +$unexpected=Join-Path $root 'unknown.bin' +[IO.File]::WriteAllText($unexpected,'R310_UNKNOWN') +$backup=Join-Path $root 'files\TaskbarMonitorSensorBroker.exe' +$before=SHA $backup +Reject 'UNKNOWN_ACTIVE_MEMBER_FAIL_CLOSED' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_JOURNAL_UNKNOWN_MEMBER' +if((SHA $backup) -cne $before){throw 'R310_UNKNOWN_MEMBER_DELETED_KNOWN_SNAPSHOT'} +[IO.File]::Delete($unexpected) +# Wrong token SID must refuse and leave full journal. +Reject 'FOREIGN_TOKEN_JOURNAL_PRESERVED' { + Finish-SensorTerminalJournal $root 'S-1-5-21-111-222-333-444' +} 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' +if((SHA $backup) -cne $before){throw 'R310_FOREIGN_SID_DESTROYED_DATA'} +$outside=Join-Path $base 'unrelated-user' +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'canary.txt' +[IO.File]::WriteAllText($canary,'R310_DO_NOT_DELETE') +$canaryHash=SHA $canary +$junction=Join-Path $root 'junction' +New-Item -ItemType Junction -Path $junction -Target $outside -ErrorAction Stop|Out-Null +Reject 'ACTIVE_JUNCTION_REFUSED' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_JOURNAL_REPARSE_MEMBER' +if((SHA $canary) -cne $canaryHash){throw 'R310_JUNCTION_TARGET_TOUCHED'} +[IO.Directory]::Delete($junction) +Finish-SensorTerminalJournal $root $sid +if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($root+'-retired')){ + throw 'R310_ROLLED_BACK_FINISH_LEFT_RESIDUE' +} +$checks.Add('ROLLBACK_TERMINAL_NONRECURSIVE_CLEANUP') +# A retired journal containing an unknown file must not be silently purged. +New-SensorDurableJournal $root $sid $rows $xml $next $true +Write-SensorJournalTerminal $root $sid 'COMMITTED' +[IO.Directory]::Move($root,$root+'-retired') +$unknown=Join-Path ($root+'-retired') 'hostile.txt' +[IO.File]::WriteAllText($unknown,'USER_ADDED_FILE') +$protectedSnapshot=Join-Path ($root+'-retired') 'files\TaskbarMonitorSensorBroker.exe' +$priorHash=SHA $protectedSnapshot +Reject 'UNKNOWN_RETIRED_MEMBER_REFUSED_BEFORE_DELETE' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_RETIRED_JOURNAL_UNKNOWN_MEMBER' +if((SHA $protectedSnapshot) -cne $priorHash){throw 'R310_UNKNOWN_RETIRED_DELETED_SNAPSHOT'} +[IO.File]::Delete($unknown) +Finish-SensorTerminalJournal $root $sid +if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($root+'-retired')){ + throw 'R310_RETIRED_RECOVERY_NOT_COMPLETE' +} +$checks.Add('UNKNOWN_RETIRED_NEGATIVE_AND_RETRY_CLEANUP') +if((SHA $canary) -cne $canaryHash -or (SHA $main) -cne $oldHashes.Main -or + (SHA $config) -cne $oldHashes.Config -or (SHA $state) -cne $oldHashes.State){ + throw 'R310_OWNER_OR_UNRELATED_CANARY_CHANGED' +} +$checks.Add('INSTALLED_MAIN_CONFIG_STATE_AND_CANARY_UNCHANGED') +$report=[ordered]@{ + Schema=1;Revision='R310';Status='PASS_SYNTHETIC_RETIRED_TERMINAL_PARTIAL_CLEANUP_RESTART' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;RealProgramFilesWritten=$false + ActualUACOrProtectedTaskModified=$false;ActualOriginalElevatedHelperKilled=$false + RealProofWorkerKilledDuringCleanup=$true;FreshProcessFinishedRetiredCleanup=$true + NonrecursiveKnownMemberDeletion=$true;UnknownFilesAndJunctionFailClosed=$true + ForeignSIDAndPreparedPhaseRefused=$true;TerminatedJournalEventuallyRemoved=$true + UserDataAndCanaryUnchanged=$true + WorkerPid=$workerPid;RestartPid=$restartPid + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($report|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R310_NATIVE_'+$HostLabel+'_PARTIAL_NONRECURSIVE_CLEANUP_PASS' diff --git a/build/Test-R311-HealthyTerminalHooks-Native.ps1 b/build/Test-R311-HealthyTerminalHooks-Native.ps1 new file mode 100644 index 00000000..9fd871a4 --- /dev/null +++ b/build/Test-R311-HealthyTerminalHooks-Native.ps1 @@ -0,0 +1,217 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R311_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function FileSha([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function MustReject([string]$label,[scriptblock]$callback,[string]$pattern){ + $rejected=$false + try{& $callback}catch{ + if($_.Exception.ToString() -match $pattern){$rejected=$true}else{throw} + } + if(-not $rejected){throw ('R311_MISSING_REQUIRED_REFUSAL_'+$label)} + $script:checks.Add($label) +} +$root=Join-Path $env:RUNNER_TEMP ('R311_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R310_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($root)){throw 'R311_TEST_ROOT_PREEXISTS'} +[void][IO.Directory]::CreateDirectory($root) +$source=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +$original=@{Main=(FileSha $main);Config=(FileSha $config);State=(FileSha $state)} +if($source.Commit -cne $env:GITHUB_SHA -or $source.SetupSHA256 -cne (FileSha $setup) -or + $source.MainSHA256 -cne $original.Main -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_SYNTHETIC_RETIRED_TERMINAL_PARTIAL_CLEANUP_RESTART'){ + throw 'R311_PREDECESSOR_OR_EXACT_PE_CANDIDATE_INVALID' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R311_TRUSTED_EMBEDDED_HELPER_MISSING'} +$reader=[IO.StreamReader]::new($res) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($helper) +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize $raw)){throw 'R311_PE_RESOURCE_SOURCE_NOT_IDENTICAL'} +$checks.Add('EMBEDDED_PE_RESOURCE_EQUALS_CURRENT_SECURITY_HELPER') +$ownerGuard=$raw.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) +$preflight=[Math]::Max( + $raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal), + $raw.IndexOf('Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName',[StringComparison]::Ordinal)) +$uninstall=$raw.IndexOf('if($Mode -eq ''Uninstall'')',[StringComparison]::Ordinal) +$writeAhead=$raw.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal) +$health=$raw.IndexOf('if(-not $taskInstalled -or -not $SupervisorHealthy)',[StringComparison]::Ordinal) +$finalize=$raw.IndexOf('Complete-HealthySensorJournal $SensorJournalRoot $validatedSensorSid $taskInstalled $SupervisorHealthy',[StringComparison]::Ordinal) +$memRelease=$raw.LastIndexOf('Clear-SensorRollbackBuffer',[StringComparison]::Ordinal) +if($ownerGuard -lt 0 -or $preflight -le $ownerGuard -or $uninstall -le $preflight -or + $writeAhead -le $uninstall -or $health -le $writeAhead -or + $finalize -le $health -or $memRelease -le $finalize){ + throw 'R311_REAL_HELPER_HOOK_ORDER_NOT_PREWRITE_OR_PRE_RESULT' +} +$checks.Add('PREWRITE_OWNER_GUARD_BEFORE_INSTALL_AND_TERMINAL_BEFORE_RESULT') +$tok=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tok,[ref]$errors) +if(@($errors).Count){throw 'R311_WINPS51_PARSE_OF_PE_RESOURCE_FAILED'} +$wanted=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique', + 'Assert-SensorDurableJournal','New-SensorDurableJournal','Get-SensorJournalTerminal', + 'Write-SensorJournalTerminal','Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resume-SensorCompletedJournal','Complete-HealthySensorJournal') +$found=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -in $wanted +},$true)) +if($found.Count -ne $wanted.Count){throw 'R311_TRUSTED_PRODUCTION_HOOK_FUNCTIONS_MISSING'} +. ([scriptblock]::Create(($found|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('FIFTEEN_PRODUCTION_SECURITY_FUNCTIONS_LOAD_NATIVE') +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R311_RUNNER_SID_NOT_INTERACTIVE'} +$admin=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admin) +$acl.SetAccessRuleProtection($true,$false) +foreach($principal in @($admin,$system)){ + $entry=[Security.AccessControl.FileSystemAccessRule]::new( + $principal,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($entry) +} +$parent=Join-Path $root 'protected-parent' +[void][IO.Directory]::CreateDirectory($parent,$acl) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('SYNTHETIC_PROTECTED_PARENT_ACL_ACCEPTED') +$stage=Join-Path $parent 'journal' +$originalData=[Text.Encoding]::ASCII.GetBytes('R311_PREVIOUS_TRUSTED_BROKER') +$rows=[object[]]@([pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=$originalData}) +$names=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','.tbme-owner.sid') +$task='R311_KNOWN_PREVIOUS' +Resume-SensorCompletedJournal $stage $sid +$checks.Add('CLEAN_FIRST_INSTALL_PRECHECK_NOOP') +function NewFixture(){ + New-SensorDurableJournal $stage $sid $rows $task $names $true + if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_NEW_FIXTURE_NOT_PREPARED' + } +} +NewFixture +MustReject 'UNHEALTHY_SUPERVISOR_NEVER_COMMITS' { + Complete-HealthySensorJournal $stage $sid $true $false +} 'SENSOR_JOURNAL_SUPERVISOR_NOT_HEALTHY_NO_COMMIT' +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_UNHEALTHY_BRANCH_REMOVED_READY' +} +MustReject 'NO_OWNED_TASK_NEVER_COMMITS' { + Complete-HealthySensorJournal $stage $sid $false $true +} 'SENSOR_JOURNAL_SUPERVISOR_NOT_HEALTHY_NO_COMMIT' +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_NO_TASK_WAS_INCORRECTLY_COMMITTED' +} +MustReject 'PARTIAL_UNCOMMITTED_JOURNAL_BLOCKS_NEW_INSTALL' { + Resume-SensorCompletedJournal $stage $sid +} 'SENSOR_JOURNAL_NOT_TERMINAL_DO_NOT_RETIRE' +if(-not [IO.Directory]::Exists($stage)){throw 'R311_PARTIAL_JOURNAL_REMOVED'} +Complete-HealthySensorJournal $stage $sid $true $true +if([IO.Directory]::Exists($stage) -or [IO.Directory]::Exists($stage+'-retired')){ + throw 'R311_HEALTHY_COMMIT_LEFT_RESIDUE' +} +$checks.Add('HEALTHY_TASK_AND_SUPERVISOR_COMMIT_AND_RETIRE') +Resume-SensorCompletedJournal $stage $sid +$checks.Add('REPEAT_ENTRYPOINT_AFTER_CLEAN_COMMIT_NOOP') +NewFixture +Write-SensorJournalTerminal $stage $sid 'COMMITTED' +Resume-SensorCompletedJournal $stage $sid +if([IO.Directory]::Exists($stage) -or [IO.Directory]::Exists($stage+'-retired')){ + throw 'R311_COLD_COMMITTED_COMPLETION_LEFT_RESIDUE' +} +$checks.Add('NEXT_PROCESS_VALID_COMMITTED_MARKER_RETIRED') +NewFixture +Write-SensorJournalTerminal $stage $sid 'ROLLED_BACK' +Resume-SensorCompletedJournal $stage $sid +if([IO.Directory]::Exists($stage) -or [IO.Directory]::Exists($stage+'-retired')){ + throw 'R311_VERIFIED_ROLLED_BACK_TERMINAL_WAS_NOT_RETIRED' +} +$checks.Add('OLD_TERMINAL_ROLLED_BACK_RETIRED_ON_ENTRY') +NewFixture +MustReject 'FOREIGN_EFFECTIVE_SID_CANNOT_COMPLETE_TERMINAL' { + Complete-HealthySensorJournal $stage 'S-1-5-21-111-222-333-444' $true $true +} 'SENSOR_JOURNAL_COMMIT_EFFECTIVE_SID_MISMATCH' +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_FOREIGN_SID_COMMITTED_JOURNAL' +} +MustReject 'FOREIGN_ID_CANNOT_CLEAN_EXISTING_READY' { + Resume-SensorCompletedJournal $stage 'S-1-5-21-111-222-333-444' +} 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' +$unknown=Join-Path $stage 'unowned.txt' +[IO.File]::WriteAllText($unknown,'R311_UNKNOWN',[Text.Encoding]::ASCII) +MustReject 'UNKNOWN_JOURNAL_MEMBER_NEVER_RETIRED' { + Resume-SensorCompletedJournal $stage $sid +} 'SENSOR_JOURNAL_UNKNOWN_MEMBER' +if(-not [IO.Directory]::Exists($stage)){throw 'R311_UNKNOWN_JOURNAL_DELETED'} +[IO.File]::Delete($unknown) +$extern=Join-Path $root 'unrelated' +[void][IO.Directory]::CreateDirectory($extern) +$canary=Join-Path $extern 'preserve.txt' +[IO.File]::WriteAllText($canary,'R311_USER_CANARY',[Text.Encoding]::ASCII) +$canarySha=FileSha $canary +$junction=Join-Path $stage 'fake-junction' +New-Item -ItemType Junction -Path $junction -Target $extern -ErrorAction Stop|Out-Null +MustReject 'JUNCTION_JOURNAL_NEVER_RETIRED' { + Resume-SensorCompletedJournal $stage $sid +} 'SENSOR_JOURNAL_REPARSE_MEMBER' +if((FileSha $canary) -cne $canarySha){throw 'R311_JUNCTION_CANARY_MODIFIED'} +[IO.Directory]::Delete($junction) +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_NEGATIVE_BRANCH_CHANGED_JOURNAL' +} +$checks.Add('PREPARED_SNAPSHOT_UNCHANGED_AFTER_NEGATIVE_CASES') +if((FileSha $main) -cne $original.Main -or + (FileSha $config) -cne $original.Config -or + (FileSha $state) -cne $original.State -or + (FileSha $canary) -cne $canarySha){ + throw 'R311_GUEST_OR_EXTERNAL_DATA_CHANGED' +} +$checks.Add('OFFICIAL_MAIN_SETTINGS_AND_OUTSIDE_CANARY_PRESERVED') +$result=[ordered]@{ + Schema=1;Revision='R311';Status='PASS_SYNTHETIC_NORMAL_HEALTHY_TERMINAL_HOOKS' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ActualProgramFilesWritten=$false + ActualUACPerformed=$false;ActualScheduledTaskModified=$false + OriginalElevatedHelperKilled=$false + ExactPeEntrypointHookOrderValidated=$true + HealthyCommittedAndRetired=$true + UnhealthyAndPreparedPreserved=$true + ColdTerminalCleanupCompleted=$true + WrongSidReparseUnknownRefused=$true + ActualTaskXmlColdRestoreDone=$false + InstalledOwnerDataUnchanged=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R311_'+$HostLabel+'_PRODUCTION_HEALTHY_HOOKS_SYNTHETIC_PASS' diff --git a/build/Test-R312-PinnedTaskXml-Native.ps1 b/build/Test-R312-PinnedTaskXml-Native.ps1 new file mode 100644 index 00000000..fa47c938 --- /dev/null +++ b/build/Test-R312-PinnedTaskXml-Native.ps1 @@ -0,0 +1,176 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R312_DISPOSABLE_WINDOWS_GUEST_ONLY' +} +function Hash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function AssertRefused([string]$name,[string]$document,[string]$expected){ + $failed=$false + try{[void](Assert-SensorTrustedOldTaskXml $document $sid $brokerRoot $appRoot)} + catch{if($_.Exception.ToString() -match $expected){$failed=$true}else{throw}} + if(-not $failed){throw ('R312_UNSAFE_TASK_XML_ACCEPTED_'+$name)} + $script:checks.Add($name) +} +function ParseDoc(){ + $document=[Xml.XmlDocument]::new();$document.XmlResolver=$null + $document.LoadXml($validXML) + return $document +} +function Ns([Xml.XmlDocument]$doc){ + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable) + $ns.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + return ,$ns +} +$root=Join-Path $env:RUNNER_TEMP ('R312_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R312_TRUSTED_TASK_XML_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R311_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($root)){throw 'R312_DUPLICATE_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($root) +$before=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (Hash $setup) -or + $c.MainSHA256 -cne $before.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_SYNTHETIC_NORMAL_HEALTHY_TERMINAL_HOOKS'){ + throw 'R312_EXACT_PRIVATE_CANDIDATE_OR_PREDECESSOR_NOT_VERIFIED' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R312_NO_EMBEDDED_PE_HELPER'} +$reader=[IO.StreamReader]::new($res) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($helper)))){ + throw 'R312_PE_HELPER_SOURCE_UNPINNED' +} +$checks.Add('EXACT_PE_SOURCE_WITH_NO_UNTRUSTED_SCRIPT_FILE') +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R312_POWERSHELL51_SOURCE_PARSER_FAILED'} +$wanted=@('Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml') +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -in $wanted +},$true)) +if($defs.Count -ne 2){throw 'R312_EXACT_PRODUCTION_XML_GUARDS_MISSING'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('TWO_PURE_PRODUCTION_PE_XML_FUNCTIONS_LOADED') +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'R312_DISPOSABLE_RUNNER_NOT_INTERACTIVE_SID' +} +$brokerRoot=Join-Path $root 'ProtectedBroker' +$appRoot=Join-Path $root 'AppRoot' +$supervisor=Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $brokerRoot 'TaskbarMonitorSensorBroker.exe' +$out=Join-Path $appRoot 'cpu_temp_broker.json' +$args='--broker "'+$broker+'" --output "'+$out+'"' +$uri='\TaskbarMonitorEnhanced Sensor Broker' +$escSid=[Security.SecurityElement]::Escape([string]$sid) +$escExe=[Security.SecurityElement]::Escape($supervisor) +$escArgs=[Security.SecurityElement]::Escape($args) +$validXML=@" + + $uri + true$escSid + $escSidInteractiveTokenHighestAvailable + truetrue + $escExe$escArgs + +"@ +if(-not (Assert-SensorTrustedOldTaskXml $validXML $sid $brokerRoot $appRoot)){ + throw 'R312_VALID_OWNED_SYNTHETIC_XML_REFUSED' +} +$checks.Add('SYNTHETIC_PRIOR_TASK_EXACT_SID_ACTION_TRIGGER_ACCEPTED') +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command',$n).InnerText='C:\Windows\System32\cmd.exe' +AssertRefused 'ALTERED_EXECUTABLE_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EXEC_NOT_PINNED' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments',$n).InnerText+=' & altered' +AssertRefused 'ARGUMENT_INJECTION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EXEC_NOT_PINNED' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId',$n).InnerText='S-1-5-21-111-222-333-444' +AssertRefused 'FOREIGN_PRINCIPAL_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Triggers/t:LogonTrigger/t:UserId',$n).InnerText='S-1-5-21-111-222-333-444' +AssertRefused 'FOREIGN_LOGON_TRIGGER_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_WRONG_LOGON_USER' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:RunLevel',$n).InnerText='LeastPrivilege' +AssertRefused 'CHANGED_PRIVILEGE_LEVEL_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:LogonType',$n).InnerText='Password' +AssertRefused 'DIFFERENT_LOGON_TYPE_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' +$d=ParseDoc;$n=Ns $d +$exec=$d.SelectSingleNode('/t:Task/t:Actions/t:Exec',$n) +$working=$d.CreateElement('WorkingDirectory','http://schemas.microsoft.com/windows/2004/02/mit/task') +$working.InnerText='C:\Windows\Temp' +[void]$exec.AppendChild($working) +AssertRefused 'UNPINNED_WORKDIR_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EXEC_SHAPE_UNEXPECTED' +$d=ParseDoc;$n=Ns $d +$trig=$d.SelectSingleNode('/t:Task/t:Triggers/t:LogonTrigger',$n) +$newTrig=$d.CreateElement('RegistrationTrigger','http://schemas.microsoft.com/windows/2004/02/mit/task') +[void]$trig.ParentNode.ReplaceChild($newTrig,$trig) +AssertRefused 'REGISTRATION_TRIGGER_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_UNEXPECTED_ACTION_OR_TRIGGER' +$d=ParseDoc;$n=Ns $d +$actions=$d.SelectSingleNode('/t:Task/t:Actions',$n) +$com=$d.CreateElement('ComHandler','http://schemas.microsoft.com/windows/2004/02/mit/task') +[void]$actions.AppendChild($com) +AssertRefused 'SECOND_ACTION_COM_HANDLER_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_UNEXPECTED_ACTION_OR_TRIGGER' +$d=ParseDoc;$n=Ns $d +$info=$d.SelectSingleNode('/t:Task/t:RegistrationInfo',$n) +$sd=$d.CreateElement('SecurityDescriptor','http://schemas.microsoft.com/windows/2004/02/mit/task') +$sd.InnerText='D:(A;;GA;;;WD)' +[void]$info.AppendChild($sd) +AssertRefused 'SECURITY_DESCRIPTOR_ESCALATION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EMBEDDED_SECURITY_DESCRIPTOR_REFUSED' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$n).InnerText='\Another Task' +AssertRefused 'TASK_URI_IMPERSONATION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_URI_UNPINNED' +$d=ParseDoc;$n=Ns $d +$principal=$d.SelectSingleNode('/t:Task/t:Principals/t:Principal',$n) +$group=$d.CreateElement('GroupId','http://schemas.microsoft.com/windows/2004/02/mit/task') +$group.InnerText='BUILTIN\Administrators' +[void]$principal.AppendChild($group) +AssertRefused 'GROUP_PRINCIPAL_INJECTION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_GROUP_PRINCIPAL_FORBIDDEN' +AssertRefused 'XML_DTD_ENTITY_PROHIBITED' (']>'+$validXML) 'DTD|DOCTYPE|XmlException' +AssertRefused 'MISSING_TASK_XML_REFUSED' '' 'SENSOR_JOURNAL_TASK_XML_MISSING_OR_OVERSIZE' +if((Hash $main) -cne $before.Main -or (Hash $config) -cne $before.Config -or + (Hash $state) -cne $before.State){ + throw 'R312_NO_REAL_TASK_TEST_MUTATED_INSTALLED_OWNER' +} +$checks.Add('INSTALLED_MAIN_AND_SETTINGS_UNCHANGED') +$record=[ordered]@{ + Schema=1;Revision='R312' + Status='PASS_EXACT_PRODUCTION_TASK_XML_POLICY_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ActualProgramFilesWritten=$false + ActualUACInvoked=$false;ActualScheduledTaskModified=$false + ExactPEPolicyFunctionsLoaded=$true;LegitimateTaskXmlAccepted=$true + CommandPrincipalTriggerAndXmlInjectionRejected=$true + InstalledOwnerDataUnchanged=$true + ProductionColdTaskRegistrationIntegrated=$false + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R312_NATIVE_'+$HostLabel+'_PINNED_TASK_XML_PURE_POLICY_PASS' diff --git a/build/Test-R313-DisposableScheduledTask-Native.ps1 b/build/Test-R313-DisposableScheduledTask-Native.ps1 new file mode 100644 index 00000000..2649c645 --- /dev/null +++ b/build/Test-R313-DisposableScheduledTask-Native.ps1 @@ -0,0 +1,216 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R313_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +Import-Module ScheduledTasks -ErrorAction Stop +function Hash([string]$name){ + if(-not [IO.File]::Exists($name)){return 'MISSING'} + return (Get-FileHash -LiteralPath $name -Algorithm SHA256).Hash +} +function Absent([string]$task){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $task -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) + if($items.Count -ne 0){throw ('R313_EPHEMERAL_TASK_UNEXPECTEDLY_PRESENT_'+$task)} +} +function ParseDocument([string]$source){ + $settings=[Xml.XmlReaderSettings]::new() + $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit + $settings.XmlResolver=$null + $settings.MaxCharactersInDocument=262144 + $sr=[IO.StringReader]::new($source) + $reader=$null + try{ + $reader=[Xml.XmlReader]::Create($sr,$settings) + $doc=[Xml.XmlDocument]::new() + $doc.XmlResolver=$null + $doc.Load($reader) + return $doc + }finally{ + if($reader){$reader.Dispose()} + $sr.Dispose() + } +} +function Namespace([Xml.XmlDocument]$doc){ + $n=[Xml.XmlNamespaceManager]::new($doc.NameTable) + $n.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + return ,$n +} +$fixture=Join-Path $env:RUNNER_TEMP ('R313_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $fixture 'R313_TASK_REGISTRATION_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$previous=Join-Path $env:RUNNER_TEMP ('R312_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R312_TRUSTED_TASK_XML_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($fixture)){throw 'R313_FIXTURE_COLLISION'} +[void][IO.Directory]::CreateDirectory($fixture) +$initial=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$source=Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json +$prior=Get-Content -LiteralPath $previous -Raw | ConvertFrom-Json +if($source.Commit -cne $env:GITHUB_SHA -or + $source.SetupSHA256 -cne (Hash $setup) -or + $source.MainSHA256 -cne $initial.Main -or + $prior.SourceCommit -cne $env:GITHUB_SHA -or + $prior.Host -cne $HostLabel -or + $prior.Status -cne 'PASS_EXACT_PRODUCTION_TASK_XML_POLICY_SYNTHETIC'){ + throw 'R313_EXACT_CANDIDATE_OR_PRIOR_NATIVE_PROOF_INVALID' +} +$checks.Add('EXACT_SOURCE_AND_R312_PREDECESSOR_BOUND') +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R313_EMBEDDED_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$norm={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm ([IO.File]::ReadAllText($helper)))){ + throw 'R313_HELPER_SOURCE_NOT_EXACT_PE' +} +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R313_PE_HELPER_PS51_PARSER_BAD'} +$desired=@('Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml') +$defs=@($ast.FindAll({param($x) + $x -is [Management.Automation.Language.FunctionDefinitionAst] -and + $x.Name -in $desired +},$true)) +if($defs.Count -ne 2){throw 'R313_PE_TASK_XML_GUARD_NOT_FOUND'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('ONLY_EXACT_PE_TASK_XML_GUARD_EXTRACTED') +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R313_HOSTED_USER_SID_NOT_INTERACTIVE'} +$taskName='TBME-R313-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel +if($taskName -cnotmatch '^TBME-R313-Disposable-[0-9]{5,20}-[AB]$'){ + throw 'R313_BAD_EPHEMERAL_TASK_NAME' +} +Absent $taskName +$realOriginal=@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) +if($realOriginal.Count -ne 0){throw 'R313_PROTECTED_SENSOR_TASK_PRESENT_ON_GUEST_REFUSE_TEST'} +$checks.Add('UNIQUE_UNUSED_EPHEMERAL_TASK_AND_NO_REAL_PROTECTED_TASK') +$brokerRoot=Join-Path $fixture 'synthetic-protected-broker' +$appRoot=Join-Path $fixture 'synthetic-user-app' +$supervisor=Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $brokerRoot 'TaskbarMonitorSensorBroker.exe' +$out=Join-Path $appRoot 'cpu_temp_broker.json' +if([IO.File]::Exists($supervisor) -or [IO.File]::Exists($broker)){ + throw 'R313_SYNTHETIC_ACTION_EXECUTABLE_ACTUALLY_PRESENT' +} +$args='--broker "'+$broker+'" --output "'+$out+'"' +$sidSafe=[Security.SecurityElement]::Escape($sid) +$commandSafe=[Security.SecurityElement]::Escape($supervisor) +$argsSafe=[Security.SecurityElement]::Escape($args) +$xml=@" + +false$sidSafe +$sidSafeInteractiveTokenHighestAvailable +falsefalsetrue +$commandSafe$argsSafe + +"@ +# The URI is intentionally omitted in the disposable XML. Task Scheduler +# assigns this task's unique name; the real fixed-owner XML uses a pinned URI. +if(-not (Assert-SensorTrustedOldTaskXml $xml $sid $brokerRoot $appRoot)){ + throw 'R313_SAFE_DISABLED_XML_REFUSED_BEFORE_REGISTRATION' +} +$checks.Add('PE_GUARD_ACCEPTS_DISABLED_NONEXECUTING_EPHEMERAL_XML') +$created=$false +$registeredTwice=$false +$exportHash='' +try{ + # Never -Force. Name was checked absent immediately before registration. + Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Xml $xml -ErrorAction Stop | Out-Null + $created=$true + $checks.Add('EPHEMERAL_TASK_REGISTERED_WITHOUT_FORCE_OR_UAC') + $reg=Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction Stop + if([string]$reg.TaskName -cne $taskName -or + [bool]$reg.Settings.Enabled -ne $false -or + [string]$reg.State -cne 'Disabled' -or + @($reg.Actions).Count -ne 1 -or + @($reg.Triggers).Count -ne 1 -or + [bool]$reg.Triggers[0].Enabled -ne $false -or + -not [string]::Equals([string]$reg.Actions[0].Execute,$supervisor,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$reg.Actions[0].Arguments,$args,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R313_REGISTERED_TASK_NOT_NONEXECUTING_AND_PINNED' + } + $checks.Add('READBACK_DISABLING_AND_EXACT_ACTION_PINNED') + $exported=[string](Export-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction Stop) + $doc=ParseDocument $exported;$n=Namespace $doc + $uriNode=$doc.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$n) + $sidNode=$doc.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId',$n) + $commandNode=$doc.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command',$n) + $argNode=$doc.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments',$n) + if(!$sidNode -or !$commandNode -or !$argNode -or + (Resolve-SensorPriorTaskPrincipalSid ([string]$sidNode.InnerText)) -cne $sid -or + -not [string]::Equals([string]$commandNode.InnerText,$supervisor,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$argNode.InnerText,$args,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R313_EXPORT_XML_PRINCIPAL_ACTION_CHANGED' + } + if($uriNode -and [string]$uriNode.InnerText -cne ('\'+$taskName)){ + throw 'R313_DISPOSABLE_TASK_URI_NOT_SCOPED' + } + $exportHash=[BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($exported)))).Replace('-','') + $checks.Add('EXPORT_XML_EXACT_TASK_NAME_SID_ACTION_VERIFIED') + Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false -ErrorAction Stop + $created=$false + Absent $taskName + $checks.Add('FIRST_EPHEMERAL_TASK_REMOVED_AND_ABSENCE_RECHECKED') + # A cold task-restore roundtrip of the same validated XML, still disabled. + Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Xml $xml -ErrorAction Stop|Out-Null + $created=$true + $registeredTwice=$true + $t=Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction Stop + if($t.State -ne 'Disabled' -or [bool]$t.Settings.Enabled -ne $false){ + throw 'R313_SECOND_REGISTER_NOT_DISABLED' + } + $checks.Add('SECOND_SAME_USER_REGISTRATION_DISABLED') +}finally{ + if($created){ + Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false -ErrorAction Stop + } +} +Absent $taskName +$checks.Add('SECOND_DISPOSABLE_TASK_FULL_CLEANUP_VERIFIED') +if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count -ne 0){ + throw 'R313_UNEXPECTED_ACTUAL_PROTECTED_TASK_CREATED' +} +$checks.Add('ACTUAL_PROTECTED_SENSOR_TASK_NEVER_PRESENT') +if([IO.File]::Exists($supervisor) -or + (Hash $main) -cne $initial.Main -or + (Hash $config) -cne $initial.Config -or + (Hash $state) -cne $initial.State){ + throw 'R313_GUEST_USER_PROGRAM_FILES_OR_FAKE_EXEC_CHANGED' +} +$checks.Add('INSTALLED_USER_DATA_AND_ACTION_TARGET_UNCHANGED') +$result=[ordered]@{ + Schema=1;Revision='R313' + Status='PASS_EPHEMERAL_DISABLED_SCHEDULED_TASK_REGISTER_EXPORT_UNREGISTER' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ActualProtectedSensorTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACPerformed=$false + RegisteredOnlyUniqueDisposableTask=$true + NeverEnabledOrExecuted=$true;NoForceRegistration=$true + ColdRegistrationPerformedOnDisposableTask=$registeredTwice + ExactPrincipalAndCommandReadback=$true + DisposableTaskAbsentAfterTest=$true;UserFilesUnchanged=$true + ProductionPriorTaskColdRestoreIntegrated=$false + ExportXmlSHA256=$exportHash + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R313_'+$HostLabel+'_SCHEDULER_EPHEMERAL_DISABLED_XML_ROUNDTRIP_PASS' diff --git a/build/Test-R314-ColdTaskReplay-Native.ps1 b/build/Test-R314-ColdTaskReplay-Native.ps1 new file mode 100644 index 00000000..88ab1251 --- /dev/null +++ b/build/Test-R314-ColdTaskReplay-Native.ps1 @@ -0,0 +1,312 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrEmpty($env:GITHUB_SHA) -or + [string]::IsNullOrEmpty($env:GITHUB_RUN_ID) -or + [string]::IsNullOrEmpty($env:RUNNER_TEMP)){ + throw 'R314_HOSTED_WINDOWS_DISPOSABLE_ONLY' +} +function FileHash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Absent([string]$name){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) + if($items.Count){throw 'R314_PREEXISTING_DISPOSABLE_TASK'} +} +function Reject([string]$label,[scriptblock]$fn,[string]$pattern){ + $failed=$false + try{& $fn}catch{if($_.Exception.ToString() -match $pattern){$failed=$true}else{throw}} + if(-not $failed){throw ('R314_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$label)} + $script:checks.Add($label) +} +$run=Join-Path $env:RUNNER_TEMP ('R314_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $run 'R314_TASK_COLD_RESTORE_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R313_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R313_TASK_REGISTRATION_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R314_COMPILED_PE_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($res) +try{$source=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $source) -cne (& $normal ([IO.File]::ReadAllText($helper)))){ + throw 'R314_SETUP_PE_HELPER_SOURCE_MISMATCH' +} +$t=$null;$e=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$e) +if(@($e).Count){throw 'R314_HELPER_PS51_PARSER_INVALID'} +$names=@( + 'Assert-SensorBoundaryNoReparse','Get-SensorJournalHash','Assert-SensorJournalRelative', + 'Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique','Assert-SensorDurableJournal', + 'New-SensorDurableJournal','Get-SensorJournalTerminal','Write-SensorJournalTerminal', + 'Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml', + 'Test-SensorOwnedTaskRegistration','Restore-SensorJournalPriorLayerAndTask' +) +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names +},$true)) +if($defs.Count -ne $names.Count){throw 'R314_EXACT_COMPILED_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +Import-Module ScheduledTasks -ErrorAction Stop +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R314_RUNNER_SID_NOT_INTERACTIVE'} +$name='TBME-R314-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel +if($name -cnotmatch '^TBME-R314-Disposable-[0-9]{5,20}-[AB]$'){throw 'R314_UNSAFE_TASK_NAME'} +$journal=Join-Path (Join-Path $run 'trusted') 'snapshot' +$live=Join-Path $run 'live-broker' +$appRoot=Join-Path $run 'user-data' +$oldBroker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$oldCfg=Join-Path $live 'settings\old.bin' +$newSupervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$outside=Join-Path $run 'unrelated' +$canary=Join-Path $outside 'leave-alone.txt' +if($Mode -ne 'Driver'){ + if(-not [string]::Equals([IO.Path]::GetFullPath($Fixture),[IO.Path]::GetFullPath($run),[StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Directory]::Exists($run)){ + throw 'R314_CHILD_FIXTURE_IDENTITY_MISMATCH' + } + if($Mode -eq 'Worker'){ + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_WORKER_PREVIOUS_TASK_NOT_PRESENT' + } + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + [IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R314_WRONG_NEW_BROKER')) + [IO.File]::WriteAllBytes($newSupervisor,[Text.Encoding]::ASCII.GetBytes('R314_WRONG_NEW_SUPERVISOR')) + [IO.File]::WriteAllText((Join-Path $run 'WORKER_CHECKPOINT'),'TASK_REMOVED_AND_LIVE_BROKER_MUTATED') + Start-Sleep -Seconds 30 + exit 0 + } + if($Mode -eq 'Recover'){ + Restore-SensorJournalPriorLayerAndTask $journal $sid $live $appRoot $name + [IO.File]::WriteAllText((Join-Path $run 'RECOVERED'),'OLD_TASK_AND_FILES_RESTORED') + exit 0 + } +} +if([IO.Directory]::Exists($run)){throw 'R314_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($run) +$initial=@{Main=(FileHash $main);Config=(FileHash $config);State=(FileHash $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (FileHash $setup) -or + $c.MainSHA256 -cne $initial.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_EPHEMERAL_DISABLED_SCHEDULED_TASK_REGISTER_EXPORT_UNREGISTER'){ + throw 'R314_CANDIDATE_OR_PREVIOUS_RECEIPT_NOT_EXACT' +} +$checks.Add('EXACT_PE_HELPER_AND_PREVIOUS_HOST_RECEIPT') +Absent $name +$existingReal=@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) +if($existingReal.Count){throw 'R314_REAL_PROTECTED_TASK_PREEXISTS_GUEST'} +$checks.Add('ONLY_UNIQUE_EPHEMERAL_TASK_ALLOWED') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) +foreach($s in @($admins,$system)){ + $entry=[Security.AccessControl.FileSystemAccessRule]::new( + $s,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($entry) +} +[void][IO.Directory]::CreateDirectory((Join-Path $run 'trusted'),$acl) +Assert-SensorJournalTrustedAcl (Join-Path $run 'trusted') $true +$checks.Add('PROTECTED_JOURNAL_DACL_OWNER_VERIFIED') +[void][IO.Directory]::CreateDirectory((Join-Path $live 'settings')) +[void][IO.Directory]::CreateDirectory($appRoot) +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R314_TRUSTED_PREVIOUS_BROKER')) +[IO.File]::WriteAllBytes($oldCfg,[Text.Encoding]::ASCII.GetBytes('R314_TRUSTED_PREVIOUS_CONFIG')) +[IO.File]::WriteAllText($canary,'R314_CANARY_IMMUTABLE') +$oldHash=FileHash $oldBroker;$cfgHash=FileHash $oldCfg;$canaryHash=FileHash $canary +$supervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$target=Join-Path $appRoot 'cpu_temp_broker.json' +$args='--broker "'+$broker+'" --output "'+$target+'"' +$escapedSid=[Security.SecurityElement]::Escape($sid) +$escapedCmd=[Security.SecurityElement]::Escape($supervisor) +$escapedArgs=[Security.SecurityElement]::Escape($args) +$xml=@" + +false$escapedSid +$escapedSidInteractiveTokenHighestAvailable +falsefalse +$escapedCmd$escapedArgs + +"@ +if(-not (Assert-SensorTrustedOldTaskXml $xml $sid $live $appRoot)){ + throw 'R314_OLD_TASK_XML_NOT_PREVALIDATED' +} +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($oldBroker)}, + [pscustomobject]@{Rel='settings\old.bin';Data=[IO.File]::ReadAllBytes($oldCfg)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\old.bin') +New-SensorDurableJournal $journal $sid $rows $xml $next $true +$checks.Add('JOURNAL_READY_WITH_HASHED_PRIOR_TASK_AND_FILES') +$created=$false +$workerPid=0 +$recoveryPid=0 +try{ + Register-ScheduledTask -TaskPath '\' -TaskName $name -Xml $xml -ErrorAction Stop|Out-Null + $created=$true + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_INITIAL_TASK_NOT_OWNED_AND_PINNED' + } + $checks.Add('DISABLED_PRIOR_SCHEDULER_TASK_REGISTERED_AND_VERIFIED') + # Reject foreign SID, tainted XML, or rogue live files before interrupting. + $foreign='S-1-5-21-101-202-303-404' + Reject 'FOREIGN_SID_COLD_REPLAY_REFUSED' { + Restore-SensorJournalPriorLayerAndTask $journal $foreign $live $appRoot $name + } 'SENSOR_COLD_JOURNAL_EFFECTIVE_SID_MISMATCH' + $taskFile=Join-Path $journal 'task.xml' + $oldXmlBytes=[IO.File]::ReadAllBytes($taskFile) + [IO.File]::WriteAllText($taskFile,'R314_TAINTED_XML') + Reject 'TAMPERED_SAVED_TASK_REFUSED_PREWRITE' { + Restore-SensorJournalPriorLayerAndTask $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' + [IO.File]::WriteAllBytes($taskFile,$oldXmlBytes) + $bad=Join-Path $live 'unknown-file.txt' + [IO.File]::WriteAllText($bad,'R314_UNKNOWN') + Reject 'UNKNOWN_LIVE_FILE_REFUSED_BEFORE_REPLAY' { + Restore-SensorJournalPriorLayerAndTask $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' + [IO.File]::Delete($bad) + if((FileHash $oldBroker) -cne $oldHash -or (FileHash $oldCfg) -cne $cfgHash -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_NEGATIVE_CASE_MUTATED_LIVE_DATA' + } + $checks.Add('ALL_NEGATIVES_LEAVE_LIVE_AND_TASK_UNCHANGED') + function Child([string]$mode){ + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'"'+ + ' -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$run+'"') + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) + } + $worker=Child Worker + if(!$worker){throw 'R314_WORKER_START_FAIL'} + $workerPid=$worker.Id + try{ + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -and + -not $worker.HasExited -and $clock.ElapsedMilliseconds -lt 18000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -or $worker.HasExited){ + throw ('R314_MUTATION_CHECKPOINT_NOT_REACHED '+$worker.StandardError.ReadToEnd()) + } + Absent $name + if((FileHash $oldBroker) -ceq $oldHash -or + (Get-SensorJournalTerminal $journal $sid) -cne 'PREPARED'){ + throw 'R314_TASK_LOSS_OR_MUTATION_NOT_REPRODUCED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R314_TEST_WORKER_KILL_NOT_REAL' + } + $checks.Add('REAL_CHILD_KILLED_AFTER_SCHEDULED_TASK_DELETION_AND_FILE_MUTATION') + }finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() + } + $child=Child Recover + if(!$child){throw 'R314_RECOVERY_CHILD_NOT_STARTED'} + $recoveryPid=$child.Id + try{ + if(-not $child.WaitForExit(22000) -or $child.ExitCode -ne 0){ + $msg=if($child.HasExited){$child.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($msg.Length -gt 1800){$msg=$msg.Substring(0,1800)} + throw ('R314_FRESH_PROCESS_TASK_RESTORE_FAILED '+$msg) + } + }finally{ + if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(9000)} + $child.Dispose() + } + if(-not [IO.File]::Exists((Join-Path $run 'RECOVERED')) -or + $recoveryPid -eq $workerPid -or + (FileHash $oldBroker) -cne $oldHash -or + (FileHash $oldCfg) -cne $cfgHash -or + [IO.File]::Exists($newSupervisor) -or + [IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired') -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_NATIVE_TASK_OR_FILES_NOT_RESTORED' + } + $checks.Add('FRESH_PROCESS_RESTORED_PRIOR_TASK_AND_EXACT_FILES') + $t=Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction Stop + if([string]$t.State -cne 'Disabled' -or + [bool]$t.Settings.Enabled -ne $false -or + [bool]$t.Triggers[0].Enabled -ne $false -or + [IO.File]::Exists($supervisor)){ + throw 'R314_RESTORED_TASK_WAS_EXECUTABLE_OR_RUN' + } + $checks.Add('DISABLED_PRIOR_TASK_REINSTATED_WITHOUT_EXECUTING_SUPERVISOR') +}finally{ + if($created){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) + if($items.Count -eq 1){ + # Delete only the exact bounded disposable name created above. + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + } + } +} +Absent $name +$checks.Add('EPHEMERAL_TASK_REMOVED_AFTER_COLD_RESTORE') +if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count -ne 0){ + throw 'R314_ACTUAL_PROTECTED_SENSOR_TASK_TOUCHED' +} +if((FileHash $main) -cne $initial.Main -or + (FileHash $config) -cne $initial.Config -or + (FileHash $state) -cne $initial.State -or + (FileHash $canary) -cne $canaryHash){ + throw 'R314_INSTALLED_USER_OR_CANARY_CHANGED' +} +$checks.Add('ACTUAL_MAIN_SETTINGS_AND_CANARY_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R314' + Status='PASS_DISPOSABLE_TASK_AND_FILES_COLD_RESTORE_AFTER_KILLED_WORKER' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerHostTouched=$false;OriginalProtectedTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACInvoked=$false + OriginalElevatedHelperKilled=$false + RealSyntheticWorkerKilled=$true + FreshProcessRestoredActualDisposableScheduledTask=$true + FilesRestoredByteExact=$true + TaskDisabledNeverExecuted=$true + JournalTerminalAndRetiredCleanupComplete=$true + ForeignSidTamperAndUnknownFileRejectedPrewrite=$true + OriginalAppDataUnchanged=$true + WorkerPid=$workerPid;RecoveryPid=$recoveryPid + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R314_NATIVE_'+$HostLabel+'_TASK_AND_FILES_COLD_REPLAY_PASS' diff --git a/build/Test-R315-ProductionColdEntrypoint-Native.ps1 b/build/Test-R315-ProductionColdEntrypoint-Native.ps1 new file mode 100644 index 00000000..c049fbeb --- /dev/null +++ b/build/Test-R315-ProductionColdEntrypoint-Native.ps1 @@ -0,0 +1,328 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrEmpty($env:GITHUB_SHA) -or + [string]::IsNullOrEmpty($env:GITHUB_RUN_ID) -or + [string]::IsNullOrEmpty($env:RUNNER_TEMP)){ + throw 'R315_HOSTED_WINDOWS_DISPOSABLE_ONLY' +} +function FileHash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Absent([string]$name){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) + if($items.Count){throw 'R315_PREEXISTING_DISPOSABLE_TASK'} +} +function Reject([string]$label,[scriptblock]$fn,[string]$pattern){ + $failed=$false + try{& $fn}catch{if($_.Exception.ToString() -match $pattern){$failed=$true}else{throw}} + if(-not $failed){throw ('R315_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$label)} + $script:checks.Add($label) +} +$run=Join-Path $env:RUNNER_TEMP ('R315_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $run 'R315_TASK_COLD_RESTORE_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R314_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R314_TASK_COLD_RESTORE_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R315_COMPILED_PE_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($res) +try{$source=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $source) -cne (& $normal ([IO.File]::ReadAllText($helper)))){ + throw 'R315_SETUP_PE_HELPER_SOURCE_MISMATCH' +} +$t=$null;$e=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$e) +if(@($e).Count){throw 'R315_HELPER_PS51_PARSER_INVALID'} +$names=@( + 'Assert-SensorBoundaryNoReparse','Get-SensorJournalHash','Assert-SensorJournalRelative', + 'Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique','Assert-SensorDurableJournal', + 'New-SensorDurableJournal','Get-SensorJournalTerminal','Write-SensorJournalTerminal', + 'Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml', + 'Test-SensorOwnedTaskRegistration','Restore-SensorJournalPriorLayerAndTask','Resume-SensorPendingJournal' +) +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names +},$true)) +if($defs.Count -ne $names.Count){throw 'R315_EXACT_COMPILED_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +Import-Module ScheduledTasks -ErrorAction Stop +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R315_RUNNER_SID_NOT_INTERACTIVE'} +$name='TBME-R314-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel +if($name -cnotmatch '^TBME-R314-Disposable-[0-9]{5,20}-[AB]$'){throw 'R315_UNSAFE_TASK_NAME'} +$journal=Join-Path (Join-Path $run 'trusted') 'snapshot' +$live=Join-Path $run 'live-broker' +$appRoot=Join-Path $run 'user-data' +$oldBroker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$oldCfg=Join-Path $live 'settings\old.bin' +$newSupervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$outside=Join-Path $run 'unrelated' +$canary=Join-Path $outside 'leave-alone.txt' +if($Mode -ne 'Driver'){ + if(-not [string]::Equals([IO.Path]::GetFullPath($Fixture),[IO.Path]::GetFullPath($run),[StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Directory]::Exists($run)){ + throw 'R315_CHILD_FIXTURE_IDENTITY_MISMATCH' + } + if($Mode -eq 'Worker'){ + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_WORKER_PREVIOUS_TASK_NOT_PRESENT' + } + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + [IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R315_WRONG_NEW_BROKER')) + [IO.File]::WriteAllBytes($newSupervisor,[Text.Encoding]::ASCII.GetBytes('R315_WRONG_NEW_SUPERVISOR')) + [IO.File]::WriteAllText((Join-Path $run 'WORKER_CHECKPOINT'),'TASK_REMOVED_AND_LIVE_BROKER_MUTATED') + Start-Sleep -Seconds 30 + exit 0 + } + if($Mode -eq 'Recover'){ + $status=Resume-SensorPendingJournal $journal $sid $live $appRoot $name + if($status -cne 'PREVIOUS_LAYER_RESTORED'){ + throw 'R315_COLD_ENTRYPOINT_DID_NOT_RETURN_RECOVERED' + } + [IO.File]::WriteAllText((Join-Path $run 'RECOVERED'),'OLD_TASK_AND_FILES_RESTORED') + exit 0 + } +} +if([IO.Directory]::Exists($run)){throw 'R315_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($run) +$initial=@{Main=(FileHash $main);Config=(FileHash $config);State=(FileHash $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (FileHash $setup) -or + $c.MainSHA256 -cne $initial.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_DISPOSABLE_TASK_AND_FILES_COLD_RESTORE_AFTER_KILLED_WORKER'){ + throw 'R315_CANDIDATE_OR_PREVIOUS_RECEIPT_NOT_EXACT' +} +$checks.Add('EXACT_PE_HELPER_AND_PREVIOUS_HOST_RECEIPT') +$helperText=[IO.File]::ReadAllText($helper) +$ownerAt=$helperText.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) +$hookAt=$helperText.IndexOf('Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName',[StringComparison]::Ordinal) +$uninstallAt=$helperText.IndexOf('if($Mode -eq ''Uninstall'')',[StringComparison]::Ordinal) +$writeAt=$helperText.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid ',[StringComparison]::Ordinal) +if($ownerAt -lt 0 -or $hookAt -le $ownerAt -or $uninstallAt -le $hookAt -or + $writeAt -le $uninstallAt){throw 'R315_HELPER_COLD_ENTRYPOINT_ORDER_INVALID'} +$checks.Add('EXACT_PE_OWNER_GUARD_COLD_REPLAY_BEFORE_NEW_MUTATION') + +Absent $name +$existingReal=@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) +if($existingReal.Count){throw 'R315_REAL_PROTECTED_TASK_PREEXISTS_GUEST'} +$checks.Add('ONLY_UNIQUE_EPHEMERAL_TASK_ALLOWED') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) +foreach($s in @($admins,$system)){ + $entry=[Security.AccessControl.FileSystemAccessRule]::new( + $s,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($entry) +} +[void][IO.Directory]::CreateDirectory((Join-Path $run 'trusted'),$acl) +Assert-SensorJournalTrustedAcl (Join-Path $run 'trusted') $true +$checks.Add('PROTECTED_JOURNAL_DACL_OWNER_VERIFIED') +# Owner-protected live broker root is required by the production preflight; +# this fixture is disposable and never touches ProgramFiles. +[void][IO.Directory]::CreateDirectory($live,$acl) +[void][IO.Directory]::CreateDirectory((Join-Path $live 'settings')) +[void][IO.Directory]::CreateDirectory($appRoot) +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R315_TRUSTED_PREVIOUS_BROKER')) +[IO.File]::WriteAllBytes($oldCfg,[Text.Encoding]::ASCII.GetBytes('R315_TRUSTED_PREVIOUS_CONFIG')) +[IO.File]::WriteAllText($canary,'R315_CANARY_IMMUTABLE') +$oldHash=FileHash $oldBroker;$cfgHash=FileHash $oldCfg;$canaryHash=FileHash $canary +$supervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$target=Join-Path $appRoot 'cpu_temp_broker.json' +$args='--broker "'+$broker+'" --output "'+$target+'"' +$escapedSid=[Security.SecurityElement]::Escape($sid) +$escapedCmd=[Security.SecurityElement]::Escape($supervisor) +$escapedArgs=[Security.SecurityElement]::Escape($args) +$xml=@" + +false$escapedSid +$escapedSidInteractiveTokenHighestAvailable +falsefalse +$escapedCmd$escapedArgs + +"@ +if(-not (Assert-SensorTrustedOldTaskXml $xml $sid $live $appRoot)){ + throw 'R315_OLD_TASK_XML_NOT_PREVALIDATED' +} +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($oldBroker)}, + [pscustomobject]@{Rel='settings\old.bin';Data=[IO.File]::ReadAllBytes($oldCfg)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\old.bin') +New-SensorDurableJournal $journal $sid $rows $xml $next $true +$checks.Add('JOURNAL_READY_WITH_HASHED_PRIOR_TASK_AND_FILES') +$created=$false +$workerPid=0 +$recoveryPid=0 +try{ + Register-ScheduledTask -TaskPath '\' -TaskName $name -Xml $xml -ErrorAction Stop|Out-Null + $created=$true + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_INITIAL_TASK_NOT_OWNED_AND_PINNED' + } + $checks.Add('DISABLED_PRIOR_SCHEDULER_TASK_REGISTERED_AND_VERIFIED') + # Reject foreign SID, tainted XML, or rogue live files before interrupting. + $foreign='S-1-5-21-101-202-303-404' + Reject 'FOREIGN_SID_COLD_REPLAY_REFUSED' { + Resume-SensorPendingJournal $journal $foreign $live $appRoot $name + } 'SENSOR_COLD_ENTRYPOINT_EFFECTIVE_TOKEN_SID_MISMATCH' + $taskFile=Join-Path $journal 'task.xml' + $oldXmlBytes=[IO.File]::ReadAllBytes($taskFile) + [IO.File]::WriteAllText($taskFile,'R315_TAINTED_XML') + Reject 'TAMPERED_SAVED_TASK_REFUSED_PREWRITE' { + Resume-SensorPendingJournal $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' + [IO.File]::WriteAllBytes($taskFile,$oldXmlBytes) + $bad=Join-Path $live 'unknown-file.txt' + [IO.File]::WriteAllText($bad,'R315_UNKNOWN') + Reject 'UNKNOWN_LIVE_FILE_REFUSED_BEFORE_REPLAY' { + Resume-SensorPendingJournal $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' + [IO.File]::Delete($bad) + if((FileHash $oldBroker) -cne $oldHash -or (FileHash $oldCfg) -cne $cfgHash -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_NEGATIVE_CASE_MUTATED_LIVE_DATA' + } + $checks.Add('ALL_NEGATIVES_LEAVE_LIVE_AND_TASK_UNCHANGED') + function Child([string]$mode){ + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'"'+ + ' -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$run+'"') + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) + } + $worker=Child Worker + if(!$worker){throw 'R315_WORKER_START_FAIL'} + $workerPid=$worker.Id + try{ + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -and + -not $worker.HasExited -and $clock.ElapsedMilliseconds -lt 18000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -or $worker.HasExited){ + throw ('R315_MUTATION_CHECKPOINT_NOT_REACHED '+$worker.StandardError.ReadToEnd()) + } + Absent $name + if((FileHash $oldBroker) -ceq $oldHash -or + (Get-SensorJournalTerminal $journal $sid) -cne 'PREPARED'){ + throw 'R315_TASK_LOSS_OR_MUTATION_NOT_REPRODUCED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R315_TEST_WORKER_KILL_NOT_REAL' + } + $checks.Add('REAL_CHILD_KILLED_AFTER_SCHEDULED_TASK_DELETION_AND_FILE_MUTATION') + }finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() + } + $child=Child Recover + if(!$child){throw 'R315_RECOVERY_CHILD_NOT_STARTED'} + $recoveryPid=$child.Id + try{ + if(-not $child.WaitForExit(22000) -or $child.ExitCode -ne 0){ + $msg=if($child.HasExited){$child.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($msg.Length -gt 1800){$msg=$msg.Substring(0,1800)} + throw ('R315_FRESH_PROCESS_TASK_RESTORE_FAILED '+$msg) + } + }finally{ + if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(9000)} + $child.Dispose() + } + if(-not [IO.File]::Exists((Join-Path $run 'RECOVERED')) -or + $recoveryPid -eq $workerPid -or + (FileHash $oldBroker) -cne $oldHash -or + (FileHash $oldCfg) -cne $cfgHash -or + [IO.File]::Exists($newSupervisor) -or + [IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired') -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_NATIVE_TASK_OR_FILES_NOT_RESTORED' + } + $checks.Add('FRESH_PROCESS_RESTORED_PRIOR_TASK_AND_EXACT_FILES') + $t=Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction Stop + if([string]$t.State -cne 'Disabled' -or + [bool]$t.Settings.Enabled -ne $false -or + [bool]$t.Triggers[0].Enabled -ne $false -or + [IO.File]::Exists($supervisor)){ + throw 'R315_RESTORED_TASK_WAS_EXECUTABLE_OR_RUN' + } + $checks.Add('DISABLED_PRIOR_TASK_REINSTATED_WITHOUT_EXECUTING_SUPERVISOR') +}finally{ + if($created){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) + if($items.Count -eq 1){ + # Delete only the exact bounded disposable name created above. + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + } + } +} +Absent $name +$checks.Add('EPHEMERAL_TASK_REMOVED_AFTER_COLD_RESTORE') +if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count -ne 0){ + throw 'R315_ACTUAL_PROTECTED_SENSOR_TASK_TOUCHED' +} +if((FileHash $main) -cne $initial.Main -or + (FileHash $config) -cne $initial.Config -or + (FileHash $state) -cne $initial.State -or + (FileHash $canary) -cne $canaryHash){ + throw 'R315_INSTALLED_USER_OR_CANARY_CHANGED' +} +$checks.Add('ACTUAL_MAIN_SETTINGS_AND_CANARY_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R315' + Status='PASS_PREPARED_PRODUCTION_ENTRYPOINT_COLD_REPLAY_DISPOSABLE_TASK' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerHostTouched=$false;OriginalProtectedTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACInvoked=$false + OriginalElevatedHelperKilled=$false + RealSyntheticWorkerKilled=$true + FreshProcessRestoredActualDisposableScheduledTask=$true + ProductionColdEntrypointInvoked=$true + FilesRestoredByteExact=$true + TaskDisabledNeverExecuted=$true + JournalTerminalAndRetiredCleanupComplete=$true + ForeignSidTamperAndUnknownFileRejectedPrewrite=$true + OriginalAppDataUnchanged=$true + WorkerPid=$workerPid;RecoveryPid=$recoveryPid + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R315_NATIVE_'+$HostLabel+'_TASK_AND_FILES_COLD_REPLAY_PASS' diff --git a/build/Test-R316-CanonicalRoot-Native.ps1 b/build/Test-R316-CanonicalRoot-Native.ps1 new file mode 100644 index 00000000..13936859 --- /dev/null +++ b/build/Test-R316-CanonicalRoot-Native.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R316_REQUIRES_DISPOSABLE_HOSTED_WINDOWS' +} +function Sha([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$folder=Join-Path $env:RUNNER_TEMP ('R316_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $folder 'R316_TRUSTED_APPROOT_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R315_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R315_TASK_COLD_RESTORE_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($folder)){throw 'R316_EXISTING_FIXTURE_REFUSED'} +[void][IO.Directory]::CreateDirectory($folder) +$before=@{Main=(Sha $main);Config=(Sha $config);State=(Sha $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (Sha $setup) -or + $c.MainSHA256 -cne $before.Main -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_PREPARED_PRODUCTION_ENTRYPOINT_COLD_REPLAY_DISPOSABLE_TASK'){ + throw 'R316_EXACT_CANDIDATE_OR_PREVIOUS_ACCEPTANCE_MISMATCH' +} +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$asm.GetType('SetupProgram',$true) +$validator=$type.GetMethod('CanonicalAppRootFromKnownFolders',$flags) +$property=$type.GetProperty('AppRoot',$flags) +if(!$validator -or !$property){throw 'R316_TESTED_PE_HAS_NO_TRUSTED_ROOT_VALIDATOR'} +$resource=$asm.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R316_PE_INSTALLER_SOURCE_NOT_EMBEDDED'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normalize={param([string]$text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($source)))){ + throw 'R316_COMPILED_SETUP_NOT_IDENTICAL_TO_REVIEWED_CSHARP' +} +$checks.Add('EXACT_PRIVATE_PE_CONTAINS_REVIEWED_CANONICAL_ROOT_CODE') +function Resolve([string]$local,[string]$profile){ + try{ + return [string]$validator.Invoke($null,([object[]]@($local,$profile))) + }catch{ + $underlying=$_.Exception.GetBaseException() + throw [InvalidOperationException]::new($underlying.Message,$underlying) + } +} +function Refuse([string]$label,[string]$local,[string]$profile,[string]$pattern){ + $rejected=$false + try{$null=Resolve $local $profile}catch{ + if($_.Exception.ToString() -match $pattern){$rejected=$true}else{throw} + } + if(-not $rejected){throw ('R316_EXPECTED_PATH_REJECTION_MISSING_'+$label)} + $script:checks.Add($label) +} +$profile=[IO.Path]::GetFullPath([Environment]::GetFolderPath('UserProfile')) +$local=[IO.Path]::GetFullPath([Environment]::GetFolderPath('LocalApplicationData')) +$expected=[IO.Path]::GetFullPath((Join-Path $local 'TaskbarMonitorEnhanced')) +$actual=[string]$property.GetValue($null,$null) +if(-not [string]::Equals($actual,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals((Resolve $local $profile),$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R316_REAL_RUNNER_KNOWN_FOLDER_ROOT_CHANGED' +} +$checks.Add('HOSTED_RUNNER_OS_KNOWN_FOLDERS_MATCH_EXACT_APPROOT') +$fakeProfile=Join-Path $folder 'synthetic-profile' +$fakeLocal=Join-Path $fakeProfile 'AppData\Local' +[void][IO.Directory]::CreateDirectory($fakeLocal) +$syntheticExpected=[IO.Path]::GetFullPath((Join-Path $fakeLocal 'TaskbarMonitorEnhanced')) +if(-not [string]::Equals((Resolve $fakeLocal $fakeProfile),$syntheticExpected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R316_SYNTHETIC_NORMAL_PROFILE_NOT_ACCEPTED' +} +$checks.Add('SYNTHETIC_MATCHING_KNOWN_FOLDER_PROFILE_ACCEPTED') +Refuse 'RELATIVE_KNOWN_FOLDER_REFUSED' 'AppData\Local' $fakeProfile 'canonical|unavailable' +Refuse 'UNC_LOCAL_FOLDER_REFUSED' '\\server\share' $fakeProfile 'UNC|canonical' +Refuse 'ROOT_ONLY_PROFILE_REFUSED' $fakeLocal ([IO.Path]::GetPathRoot($fakeProfile)) 'root-only|LocalAppData' +Refuse 'REDIRECTED_KNOWN_FOLDER_REFUSED' $folder $fakeProfile 'canonical local folder' +Refuse 'PARENT_TRAVERSAL_OUT_OF_LOCAL_REFUSED' (Join-Path $fakeLocal '..\Other') $fakeProfile 'canonical local folder' +Refuse 'OTHER_USER_PROFILE_REFUSED' $fakeLocal (Join-Path $folder 'different-profile') 'canonical local folder' +$outside=Join-Path $folder 'unrelated' +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'untouched.txt' +[IO.File]::WriteAllText($canary,'R316_EXTERNAL_CANARY',[Text.Encoding]::ASCII) +$canaryHash=Sha $canary +$target=Join-Path $fakeLocal 'TaskbarMonitorEnhanced' +New-Item -ItemType Junction -Path $target -Target $outside -ErrorAction Stop|Out-Null +try{ + Refuse 'APP_ROOT_JUNCTION_REFUSED' $fakeLocal $fakeProfile 'reparse point' +}finally{ + [IO.Directory]::Delete($target) +} +$alias=Join-Path $folder 'profile-junction' +New-Item -ItemType Junction -Path $alias -Target $fakeProfile -ErrorAction Stop|Out-Null +try{ + Refuse 'PROFILE_PARENT_JUNCTION_REFUSED' (Join-Path $alias 'AppData\Local') $alias 'reparse point' +}finally{[IO.Directory]::Delete($alias)} +$localAlias=Join-Path $fakeProfile 'AppData\linked-Local' +New-Item -ItemType Junction -Path $localAlias -Target $fakeLocal -ErrorAction Stop|Out-Null +try{ + Refuse 'REDIRECTED_LINKED_LOCAL_FOLDER_REFUSED' $localAlias $fakeProfile 'canonical local folder|reparse point' +}finally{[IO.Directory]::Delete($localAlias)} +if((Sha $main) -cne $before.Main -or (Sha $config) -cne $before.Config -or + (Sha $state) -cne $before.State -or (Sha $canary) -cne $canaryHash){ + throw 'R316_GUEST_APP_DATA_OR_UNRELATED_CANARY_CHANGED' +} +$checks.Add('GUEST_INSTALLED_APP_AND_CANARY_BYTE_EXACT') +$receiptData=[ordered]@{ + Schema=1;Revision='R316';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Status='PASS_OS_KNOWNFOLDER_CANONICAL_APPROOT_AND_REPARSE_REFUSAL' + ExactCompiledInstallerSource=$true;CanonicalExistingAppRootAccepted=$true + SyntheticSafeProfileAccepted=$true;RelativeUNCTraversalAndForeignProfileRejected=$true + ExistingJunctionAndAliasRejected=$true;UserFilesAndExternalCanaryUnchanged=$true + OriginalOwnerMachineTouched=$false;ActualProtectedTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACInvoked=$false + CodeQLHighAlertsAccepted=$false + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R316_'+$HostLabel+'_TRUSTED_KNOWNFOLDER_ROOT_BOUNDS_PASS' diff --git a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 new file mode 100644 index 00000000..12e566b8 --- /dev/null +++ b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 @@ -0,0 +1,232 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R317_DISPOSABLE_WINDOWS_ONLY' +} +function Hash([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$run=Join-Path $env:RUNNER_TEMP ('R317_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $run 'R317_ENCODED_CLEANUP_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R316_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R316_TRUSTED_APPROOT_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($run)){throw 'R317_TEST_ROOT_PREEXISTS'} +[void][IO.Directory]::CreateDirectory($run) +$old=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (Hash $setup) -or + $c.MainSHA256 -cne $old.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_OS_KNOWNFOLDER_CANONICAL_APPROOT_AND_REPARSE_REFUSAL'){ + throw 'R317_EXACT_PRIVATE_PE_OR_R316_PREDECESSOR_MISMATCH' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$sourceStream=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$sourceStream){throw 'R317_SETUP_SOURCE_NOT_EMBEDDED'} +$reader=[IO.StreamReader]::new($sourceStream) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normalize={param([string]$text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($source)))){ + throw 'R317_COMPILED_PRIVATE_SETUP_NOT_REVIEWED_SOURCE' +} +$checks.Add('EXACT_PE_PRIVATE_INSTALLER_EMBEDDED_SOURCE_PINNED') +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) +$rootProp=$type.GetProperty('AppRoot',$flags) +if(!$builder -or !$rootProp){throw 'R317_NO_DEFERRED_CLEANUP_FACTORY_IN_PE'} +$checks.Add('PURE_PRIVATE_CLEANUP_FACTORY_PRESENT') +$root=[string]$rootProp.GetValue($null,$null) +$expected=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced')) +if(-not [string]::Equals($root,$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_OS_OWNER_CANONICAL_ROOT_CHANGED' +} +$pidSelf=[Diagnostics.Process]::GetCurrentProcess() +try{$selfPid=[int]$pidSelf.Id;$selfTicks=[long]$pidSelf.StartTime.ToUniversalTime().Ticks}finally{$pidSelf.Dispose()} +$psi=$builder.Invoke($null,[object[]]@($root,$selfPid,$selfTicks)) +$prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' +if(-not $psi.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ') -or + $psi.Arguments.Contains('ExecutionPolicy Bypass') -or + $psi.Arguments.Contains($root) -or $psi.Arguments.Length -gt 22080){ + throw 'R317_UNSAFE_UNENCODED_CLEANUP_TRANSPORT' +} +$checks.Add('ONLY_BOUNDED_UTF16_ENCODED_COMMAND_WITHOUT_LITERAL_USER_PATH') +$trusted=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +if(-not [string]::Equals($psi.FileName,$trusted,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + $psi.CreateNoWindow -ne $true -or + $psi.WorkingDirectory -cnotmatch '^(?i)[A-Z]:\\' -or + [string]::Equals($psi.WorkingDirectory,$root,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_UNTRUSTED_CLEANUP_HOST_OR_CWD' +} +$checks.Add('ABSOLUTE_SYSTEM32_POWERSHELL_NOT_CMD_OR_APP_CWD') +$code=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($psi.Arguments.Substring($prefix.Length))) +$tokens=$null;$errors=$null +[void][Management.Automation.Language.Parser]::ParseInput($code,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R317_ACTUAL_IN_MEMORY_CODE_PS51_PARSER_FAILED'} +$checks.Add('EMBEDDED_CLEANUP_SCRIPT_WINPS51_PARSER_ACCEPTED') +$match=[regex]::Match($code,'(?m)^\$root=\[Text\.Encoding\]::UTF8\.GetString\(\[Convert\]::FromBase64String\(''([A-Za-z0-9+/=]+)''\)\)') +if(-not $match.Success -or + -not [string]::Equals([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($match.Groups[1].Value)),$root,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_APP_ROOT_NOT_IN_SAFE_BASE64_DATA_SLOT' +} +$checks.Add('APP_ROOT_UTF8_BASE64_DATA_ROUNDTRIP_BYTE_EXACT') +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if(-not $code.Contains("$" + "expectedSid='$sid'") -or + -not $code.Contains("$" + "expectedPid=[int]$selfPid") -or + -not $code.Contains("$" + "expectedStart=[long]$selfTicks")){ + throw 'R317_EXPECTED_ACCOUNT_PID_START_BINDINGS_MISSING' +} +$checks.Add('EFFECTIVE_SID_PARENT_PID_AND_STARTTIME_BOUND') +foreach($literal in @( + 'TBME_CLEANUP_WRONG_EFFECTIVE_TOKEN', + 'TBME_CLEANUP_ROOT_NOT_CANONICAL', + 'TBME_CLEANUP_ANCESTOR_REPARSE', + 'TBME_CLEANUP_CHILD_REPARSE', + 'TBME_CLEANUP_EXCESSIVE_ENTRY_COUNT', + '[IO.Directory]::Delete($dir,$false)' +)){ + if(-not $code.Contains($literal)){throw ('R317_SCRIPT_PROTECTION_MISSING '+$literal)} +} +$checks.Add('LATE_ROOT_SID_AND_NONFOLLOWING_DESCENDANT_GUARDS_PRESENT') +if($code.Contains('[IO.Directory]::Delete($expectedRoot,$true)') -or + -not $code.Contains('[IO.File]::Delete($file)') -or + -not $code.Contains('TBME_CLEANUP_PATH_ESCAPED_OWNER_ROOT') -or + -not $code.Contains('TBME_CLEANUP_CHANGED_DIRECTORY')){ + throw 'R317_RECURSIVE_OR_UNBOUNDED_CLEANUP_NOT_PERMITTED' +} +$checks.Add('NONRECURSIVE_EMPTY_DIRECTORY_DELETION_WITH_REPARSE_PRECHECK') +$fragmentStart=$code.IndexOf('$cursor=$expectedRoot',[StringComparison]::Ordinal) +$fragmentEndMarker='[IO.Directory]::Delete($dir,$false)' +$fragmentEnd=$code.LastIndexOf($fragmentEndMarker,[StringComparison]::Ordinal) +if($fragmentStart -lt 0 -or $fragmentEnd -le $fragmentStart -or + $code.Contains('[IO.Directory]::Delete($expectedRoot,$true)')){ + throw 'R317_EXACT_PRODUCTION_DELETION_FRAGMENT_NOT_SAFE' +} +$fragmentClose=$code.IndexOf('}',$fragmentEnd+$fragmentEndMarker.Length) +if($fragmentClose -le $fragmentEnd -or + $code.Substring($fragmentEnd+$fragmentEndMarker.Length,$fragmentClose-($fragmentEnd+$fragmentEndMarker.Length)).Trim().Length -ne 0){ + throw 'R317_INVALID_END_OF_NONRECURSIVE_FOREACH' +} +$fragment=$code.Substring($fragmentStart,$fragmentClose+1-$fragmentStart) +$tokFragment=$null;$errFragment=$null +[void][Management.Automation.Language.Parser]::ParseInput($fragment,[ref]$tokFragment,[ref]$errFragment) +if(@($errFragment).Count){throw 'R317_NONRECURSIVE_FRAGMENT_INVALID_SYNTAX'} +$removeSynthetic=[scriptblock]::Create($fragment) +$checks.Add('EXACT_PE_NONRECURSIVE_DELETE_FRAGMENT_EXTRACTED_AND_PARSED') +$isolated=Join-Path $run 'native-only-delete-fixture' +$nested=Join-Path $isolated 'nested' +[void][IO.Directory]::CreateDirectory($nested) +[IO.File]::WriteAllText((Join-Path $isolated 'first.txt'),'R317_SYNTHETIC_LEAF_A') +[IO.File]::WriteAllText((Join-Path $nested 'second.txt'),'R317_SYNTHETIC_LEAF_B') +$expectedRoot=$isolated +if(-not [IO.Path]::GetFullPath($expectedRoot).StartsWith(([IO.Path]::GetFullPath($run).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_FIXTURE_NOT_CHILD_OF_HOSTED_RUNNER_TEMP' +} +& $removeSynthetic +if([IO.Directory]::Exists($isolated)){throw 'R317_NONRECURSIVE_NATIVE_DELETE_DID_NOT_FINISH'} +$checks.Add('ONLY_HOSTED_SYNTHETIC_TREE_NONRECURSIVELY_DELETED') +$external=Join-Path $run 'external-untouched-canary' +[void][IO.Directory]::CreateDirectory($external) +$canary=Join-Path $external 'preserve.txt' +[IO.File]::WriteAllText($canary,'R317_KEEP_EXTERNAL_DATA') +$canarySha=Hash $canary +$blocked=Join-Path $run 'native-only-junction-refusal' +[void][IO.Directory]::CreateDirectory($blocked) +$link=Join-Path $blocked 'unknown-junction' +New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null +$expectedRoot=$blocked +$refused=$false +try{& $removeSynthetic}catch{ + if($_.Exception.ToString() -match 'TBME_CLEANUP_CHILD_REPARSE'){$refused=$true}else{throw} +} +if(-not $refused -or (Hash $canary) -cne $canarySha -or + -not [IO.Directory]::Exists($blocked)){throw 'R317_CHILD_JUNCTION_WAS_FOLLOWED'} +[IO.Directory]::Delete($link,$false) +$checks.Add('JUNCTION_DESCENDANT_REFUSED_WITH_NO_EXTERNAL_DELETION') +$rootJunction=Join-Path $run 'native-only-root-junction' +New-Item -ItemType Junction -Path $rootJunction -Target $external -ErrorAction Stop|Out-Null +$expectedRoot=$rootJunction +$rootRefused=$false +try{& $removeSynthetic}catch{ + if($_.Exception.ToString() -match 'TBME_CLEANUP_ANCESTOR_REPARSE'){$rootRefused=$true}else{throw} +} +if(-not $rootRefused -or (Hash $canary) -cne $canarySha){ + throw 'R317_ROOT_JUNCTION_WAS_FOLLOWED' +} +[IO.Directory]::Delete($rootJunction,$false) +$checks.Add('ROOT_JUNCTION_REFUSED_WITH_EXTERNAL_CANARY_INTACT') + +$sourceText=[IO.File]::ReadAllText($source) +if($sourceText.Contains('tbme_cleanup_') -or + $sourceText.Contains('rmdir /s /q') -or + $sourceText.Contains('new ProcessStartInfo("cmd.exe"') -or + $sourceText.Contains('Process.Start(new ProcessStartInfo("cmd.exe"')){ + throw 'R317_MUTABLE_TEMP_BATCH_OR_CMD_STILL_IN_PRODUCTION_SETUP' +} +$checks.Add('REMOVED_MUTABLE_TEMP_BATCH_AND_UNQUALIFIED_CMD_SINK') +function MustRefuse([string]$name,[string]$path,[int]$parentProcessId,[long]$ticks){ + $rejected=$false + try{$null=$builder.Invoke($null,([object[]]@($path,$parentProcessId,$ticks)))} + catch{ + if($_.Exception.GetBaseException().Message -match 'Uninstall cleanup requires the exact owner root'){ + $rejected=$true + }else{throw} + } + if(-not $rejected){throw ('R317_FAIL_OPEN_'+$name)} + $script:checks.Add($name) +} +MustRefuse 'FOREIGN_SYSTEM_ROOT_REFUSED' 'C:\Windows' $selfPid $selfTicks +MustRefuse 'UNC_ROOT_REFUSED' '\\server\share' $selfPid $selfTicks +MustRefuse 'INJECTED_COMMAND_PATH_REFUSED' 'C:\Windows&whoami' $selfPid $selfTicks +MustRefuse 'CMD_PERCENT_VARIABLE_PATH_REFUSED' 'C:\Test%TBME_R317_ATTACK_PROBE%\TaskbarMonitorEnhanced' $selfPid $selfTicks +MustRefuse 'ZERO_PARENT_PID_REFUSED' $root 0 $selfTicks +MustRefuse 'INVALID_PARENT_STARTTIME_REFUSED' $root $selfPid 0 +MustRefuse 'SUBFOLDER_NOT_APP_ROOT_REFUSED' (Join-Path $root 'Logs') $selfPid $selfTicks +if((Hash $main) -cne $old.Main -or (Hash $config) -cne $old.Config -or + (Hash $state) -cne $old.State){ + throw 'R317_USER_APP_CHANGED_BY_TRANSPORT_ONLY_PROOF' +} +$checks.Add('NO_INSTALLATION_OR_SETTINGS_MUTATED') +$checks.Add('NO_CLEANUP_PROCESS_LAUNCHED_IN_TEST') +$receiptObject=[ordered]@{ + Schema=1;Revision='R317' + Status='PASS_EXACT_PE_INMEMORY_UNINSTALL_CLEANUP_TRANSPORT_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + MainInstallationUntouched=$true;ActualCleanupProcessLaunched=$false + ActualDirectoryDeleted=$false;OriginalOwnerComputerTouched=$false + NoMutableTempBatchOrCmd=$true;ProcessHostAbsolutePinned=$true + ShellArgumentsPureBase64=$true;WrongRootsAndInjectionRefused=$true + ScriptSyntaxAndTokenGuardVerified=$true;UserDataUnchanged=$true + SyntheticNonrecursiveDeletionVerified=$true;SyntheticJunctionRefusalVerified=$true + TrueUninstallCleanupEndToEndProven=$false + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($receiptObject|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R317_'+$HostLabel+'_SAFE_UNINSTALL_TRANSPORT_NOT_LIVE_CLEANUP_PASS' diff --git a/build/Test-R318-InProcessShortcut-Native.ps1 b/build/Test-R318-InProcessShortcut-Native.ps1 new file mode 100644 index 00000000..dbecce32 --- /dev/null +++ b/build/Test-R318-InProcessShortcut-Native.ps1 @@ -0,0 +1,166 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R318_DISPOSABLE_WINDOWS_GUEST_ONLY' +} +function SHA([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$dir=Join-Path $env:RUNNER_TEMP ('R318_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $dir 'R318_INPROCESS_SHORTCUT_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R317_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R317_ENCODED_CLEANUP_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($dir)){throw 'R318_TEST_FIXTURE_ALREADY_PRESENT'} +[void][IO.Directory]::CreateDirectory($dir) +$initial=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $initial.Main -or + $p.Host -cne $HostLabel -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Status -cne 'PASS_EXACT_PE_INMEMORY_UNINSTALL_CLEANUP_TRANSPORT_SYNTHETIC'){ + throw 'R318_EXACT_CANDIDATE_OR_R317_PROOF_NOT_VERIFIED' +} +$checks.Add('EXACT_PE_CANDIDATE_AND_PREVIOUS_UNINSTALL_PROOF') +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$asm.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$stream){throw 'R318_COMPILATION_SOURCE_NOT_EMBEDDED'} +$reader=[IO.StreamReader]::new($stream) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$file=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +$raw=[IO.File]::ReadAllText($file) +if((& $normalize $embedded) -cne (& $normalize $raw)){ + throw 'R318_EXACT_PE_SOURCE_DIFFERS_FROM_PRODUCTION' +} +$checks.Add('EXACT_PE_EMBEDDED_SHORTCUT_SOURCE_MATCH') +$type=$asm.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$paramTypes=[Type[]]@([string],[string],[string],[string]) +$method=$type.GetMethod('Shortcut',$flags,$null,$paramTypes,$null) +$rootProperty=$type.GetProperty('AppRoot',$flags) +if(-not $method -or -not $rootProperty){throw 'R318_EXACT_FOUR_ARGUMENT_COM_SHORTCUT_METHOD_MISSING'} +$checks.Add('EXACT_PRIVATE_FOUR_ARG_METHOD_REFLECTED') +$open=$raw.IndexOf('static void Shortcut(string path,string target,string arguments,string description)',[StringComparison]::Ordinal) +$close=$raw.IndexOf('static string Sha256File(string path)',[StringComparison]::Ordinal) +if($open -lt 0 -or $close -le $open){throw 'R318_COM_SHORTCUT_METHOD_ANCHOR_INVALID'} +$implementation=$raw.Substring($open,$close-$open) +foreach($unsafe in @('tbme_shortcut_','ExecutionPolicy Bypass','Process.Start(','ProcessStartInfo(','File.WriteAllText(','powershell.exe','cmd.exe')){ + if($implementation.IndexOf($unsafe,[StringComparison]::OrdinalIgnoreCase) -ge 0){ + throw ('R318_MUTABLE_SCRIPT_OR_PROCESS_STILL_IN_SHORTCUT '+$unsafe) + } +} +foreach($needed in @('WScript.Shell','CreateShortcut','BindingFlags.SetProperty','InvokeMember("Save"','FinalReleaseComObject')){ + if(-not $implementation.Contains($needed)){throw ('R318_INPROCESS_COM_PRIMITIVE_MISSING '+$needed)} +} +$checks.Add('NO_TEMP_SCRIPT_NO_EXTERNAL_PROCESS_IN_SHORTCUT_METHOD') +$target=Join-Path ([Environment]::SystemDirectory) 'notepad.exe' +if(-not [IO.File]::Exists($target)){throw 'R318_SYNTHETIC_TARGET_NOT_PRESENT'} +$link=Join-Path $dir "R318 ' % & harmless.lnk" +$arguments='--startup-recovery "%PATH%" & echo harmless; | $literal' +$description="R318 literal ' quotes & percent % untouched" +$wsh=$null;$shortcut=$null +try{ + if([IO.File]::Exists($link)){throw 'R318_EPHEMERAL_SHORTCUT_ALREADY_PRESENT'} + $null=$method.Invoke($null,[object[]]@([string]$link,[string]$target,[string]$arguments,[string]$description)) + if(-not [IO.File]::Exists($link)){throw 'R318_COM_SHORTCUT_NOT_PERSISTED'} + $checks.Add('COM_CREATED_ONLY_DISPOSABLE_LINK_FILE') + $wsh=New-Object -ComObject WScript.Shell + $shortcut=$wsh.CreateShortcut($link) + $actualTarget=[IO.Path]::GetFullPath([string]$shortcut.TargetPath) + if(-not [string]::Equals($actualTarget,$target,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R318_COM_SHORTCUT_TARGET_CHANGED' + } + $checks.Add('SAVED_SHORTCUT_TARGET_EXACT_AFTER_COM_READBACK') + if([string]$shortcut.Arguments -cne $arguments){ + throw 'R318_SHORTCUT_LITERAL_METACHARACTERS_CHANGED' + } + $checks.Add('LITERAL_COMMAND_METACHARACTER_ARGS_ARE_DATA_NOT_SCRIPT') + if([string]$shortcut.Description -cne $description){ + throw 'R318_SHORTCUT_DESCRIPTION_CHANGED' + } + $checks.Add('SHORTCUT_DESCRIPTION_WITH_QUOTE_ROUNDTRIP') + $root=[string]$rootProperty.GetValue($null,$null) + if(-not [string]::Equals([string]$shortcut.WorkingDirectory,$root,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R318_SHORTCUT_WORKING_DIRECTORY_NOT_OWNER_APPROOT' + } + $checks.Add('SHORTCUT_WORKING_DIRECTORY_PINNED_TO_OWNER_ROOT') + if([int]$shortcut.WindowStyle -ne 1){throw 'R318_SAVED_SHORTCUT_WINDOWSTYLE_NOT_EXPECTED'} + $checks.Add('SAVED_SHORTCUT_WINDOWSTYLE_ONE') + $icon=[string]$shortcut.IconLocation + if($icon -notmatch '(?i)TaskbarMonitorEnhanced\.exe\s*,\s*0$'){ + throw 'R318_ICON_LOCATION_NOT_OWNER_EXECUTABLE' + } + $checks.Add('SAVED_SHORTCUT_ICON_LOCATION_PINNED') +}finally{ + foreach($obj in @($shortcut,$wsh)){ + if($null -ne $obj -and [Runtime.InteropServices.Marshal]::IsComObject($obj)){ + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($obj) + } + } + if([IO.File]::Exists($link)){[IO.File]::Delete($link)} +} +if([IO.File]::Exists($link)){throw 'R318_SHORTCUT_FIXTURE_CLEANUP_FAILED'} +$checks.Add('SYNTHETIC_LINK_REMOVED_AFTER_TEST') +function Reject([string]$label,[string]$badLink,[string]$badTarget,[string]$needle){ + $denied=$false + try{$null=$method.Invoke($null,[object[]]@($badLink,$badTarget,'','bad'))} + catch{ + if($_.Exception.GetBaseException().Message -match $needle){$denied=$true}else{throw} + } + if(-not $denied){throw ('R318_UNSAFE_INPUT_ACCEPTED_'+$label)} + $script:checks.Add($label) +} +Reject 'RELATIVE_LINK_REJECTED' 'shortcut.lnk' $target 'absolute' +Reject 'WRONG_EXTENSION_REJECTED' (Join-Path $dir 'bad.ps1') $target 'absolute' +Reject 'RELATIVE_TARGET_REJECTED' (Join-Path $dir 'bad-target.lnk') 'notepad.exe' 'absolute' +Reject 'MISSING_LINK_DIRECTORY_REJECTED' (Join-Path $dir 'missing\bad.lnk') $target 'directory is not available' +$remains=@([IO.Directory]::GetFileSystemEntries($dir)) +if($remains.Count -ne 0){throw 'R318_EPHEMERAL_LINK_RESIDUE_OR_SCRIPT'} +$checks.Add('NO_EXTRA_TEMP_EXECUTABLE_OR_SHORTCUT_FIXTURE_RESIDUE') +if((SHA $main) -cne $initial.Main -or (SHA $config) -cne $initial.Config -or + (SHA $state) -cne $initial.State){ + throw 'R318_HOSTED_INSTALLED_USER_FILES_CHANGED' +} +$checks.Add('HOSTED_INSTALLED_MAIN_CONFIG_STATE_BYTE_EXACT') +$record=[ordered]@{ + Schema=1;Revision='R318' + Status='PASS_EXACT_PE_INPROCESS_COM_SHORTCUT_AND_NO_TEMP_SCRIPT' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + ActualProgramFilesWritten=$false;ActualUACInvoked=$false + OriginalProtectedSensorTaskTouched=$false + ActualInstalledShortcutChanged=$false + ExactPeSourceAndCOMMethodVerified=$true + WScriptShortcutCreatedReadAndRemoved=$true + AllShortcutPropertiesRoundtripped=$true + PowerShellTempScriptEliminated=$true + MetacharactersTreatedAsData=$true + RelativeAndInvalidPathsRefused=$true + RealInstalledUserDataUnchanged=$true + RealOwnerInstallationOrUninstallInvoked=$false + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R318_'+$HostLabel+'_EXACT_PE_COM_SHORTCUT_NO_TEMP_SCRIPT_SYNTHETIC_PASS' diff --git a/build/Test-R319-ActualHostedInstallUninstall-Native.ps1 b/build/Test-R319-ActualHostedInstallUninstall-Native.ps1 new file mode 100644 index 00000000..d11d9834 --- /dev/null +++ b/build/Test-R319-ActualHostedInstallUninstall-Native.ps1 @@ -0,0 +1,254 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R319_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function Hash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Absent([string]$path,[string]$name){ + if(Test-Path -LiteralPath $path){throw ('R319_EXPECTED_PRETEST_ABSENCE_'+$name)} +} +function ExecuteBounded([string]$file,[string[]]$childArguments,[int]$timeoutMs){ + if(-not [IO.File]::Exists($file) -or $childArguments.Count -lt 1){ + throw 'R319_UNTRUSTED_EXEC_OR_EMPTY_ARGS' + } + $info=[Diagnostics.ProcessStartInfo]::new() + $info.FileName=[IO.Path]::GetFullPath($file) + $info.UseShellExecute=$false + $info.CreateNoWindow=$true + # Windows PowerShell 5.1 runs on .NET Framework, whose + # ProcessStartInfo does not expose ArgumentList. Only fixed, tested, + # whitespace-free CLI switches are legal here (no shell parsing). + $allowed=@('/install','/quiet','/desktop=0','/startup=0','/uninstall') + foreach($a in $childArguments){ + if($allowed -cnotcontains [string]$a -or + [string]$a -cnotmatch '^/[a-z0-9=-]+$'){ + throw 'R319_UNSUPPORTED_OR_INJECTABLE_SETUP_ARGUMENT' + } + } + $info.Arguments=($childArguments -join ' ') + $p=$null + try{ + $p=[Diagnostics.Process]::Start($info) + if(!$p){throw 'R319_CHILD_NOT_STARTED'} + if(!$p.WaitForExit($timeoutMs)){ + if(-not $p.HasExited){$p.Kill();[void]$p.WaitForExit(10000)} + throw 'R319_BOUND_CHILD_PROCESS_TIMEOUT' + } + return [int]$p.ExitCode + }finally{if($p){$p.Dispose()}} +} +$testRoot=Join-Path $env:RUNNER_TEMP ('R319_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R319_PRIVATE_INSTALL_UNINSTALL_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$uninstall=Join-Path $app 'Uninstall.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$programRoot=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' +$brokerRoot=Join-Path $programRoot 'SensorBroker' +$startDir=[Environment]::GetFolderPath('Programs') +$startLink=Join-Path $startDir 'Taskbar Monitor Enhanced.lnk' +$repairLink=Join-Path $startDir 'Taskbar Monitor Enhanced - Repair Hardware Sensors.lnk' +$desktopLink=Join-Path ([Environment]::GetFolderPath('DesktopDirectory')) 'Taskbar Monitor Enhanced.lnk' +$startupLink=Join-Path ([Environment]::GetFolderPath('Startup')) 'Taskbar Monitor Enhanced Startup Recovery.lnk' +$uninstallKey='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$runKey='HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($testRoot)){throw 'R319_PREEXISTING_GUEST_RECEIPT_ROOT'} +[void][IO.Directory]::CreateDirectory($testRoot) +$installExit=-999 +$uninstallExit=-999 +$installedMainHash='' +$installedUninstallHash='' +$sensorStatus='' +$failure='' +$cleanupSeconds=0 +$originalUserState='EMPTY_DISPOSABLE_RUNNER' +# Always write a typed failure receipt, even if candidate manifest validation +# fails before assigning the production file hashes. +$candidate=[pscustomobject]@{MainSHA256='';SetupSHA256=''} +try{ + $candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json + if($candidate.Commit -cne $env:GITHUB_SHA -or + $candidate.Version -cne '1.7.8' -or + $candidate.Private -ne $true -or + $candidate.NotForPublicInstallation -ne $true -or + $candidate.SetupSHA256 -cne (Hash $setup)){ + throw 'R319_UNVERIFIED_PRIVATE_SETUP_CANDIDATE' + } + $checks.Add('EXACT_PRIVATE_SETUP_COMMIT_AND_SHA_BEFORE_EXECUTION') + if(-not [Environment]::Is64BitOperatingSystem){ + throw 'R319_WINDOWS_64_BIT_REQUIRED' + } + foreach($entry in @( + [pscustomobject]@{P=$app;N='APP_ROOT'}, + [pscustomobject]@{P=$startLink;N='START_MENU'}, + [pscustomobject]@{P=$repairLink;N='REPAIR_LINK'}, + [pscustomobject]@{P=$desktopLink;N='DESKTOP_LINK'}, + [pscustomobject]@{P=$startupLink;N='STARTUP_LINK'}, + [pscustomobject]@{P=$uninstallKey;N='UNINSTALL_REGISTRY'}, + [pscustomobject]@{P=$brokerRoot;N='PROTECTED_BROKER_ROOT'} + )){Absent $entry.P $entry.N} + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R319_PREEXISTING_PROTECTED_TASK' + } + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R319_PREEXISTING_PRODUCT_PROCESS' + } + $startBag=Get-ItemProperty -LiteralPath $runKey -ErrorAction SilentlyContinue + if($startBag -and $startBag.PSObject.Properties.Name -contains 'TaskbarMonitorEnhanced'){ + throw 'R319_PREEXISTING_RUN_VALUE' + } + $checks.Add('BRAND_NEW_HOSTED_USER_APP_REGISTRY_SHORTCUTS_AND_TASK') + # Installed driver is never explicitly installed from a writable Temp + # PawnIO executable by this Setup. Accept graceful sensor degradation. + $setupSource=Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs') -Raw + if(-not $setupSource.Contains('require separate') -or + -not $setupSource.Contains('official installation with explicit owner consent') -or + $setupSource.Contains('PawnIO_setup.exe",Path.Combine(payload')){ + throw 'R319_PREVENT_UNVERIFIED_THIRD_PARTY_DRIVER_INSTALL' + } + $checks.Add('NO_PAWNIO_DRIVER_EXECUTABLE_IS_LAUNCHED_BY_SETUP_SOURCE') + $installExit=ExecuteBounded $setup ([string[]]@('/install','/quiet','/desktop=0','/startup=0')) 210000 + if($installExit -ne 0){throw ('R319_REAL_PRIVATE_SETUP_INSTALL_EXIT_'+$installExit)} + $checks.Add('ACTUAL_PRIVATE_QUIET_INSTALL_PROCESS_EXIT_ZERO') + if(-not [IO.File]::Exists($main) -or -not [IO.File]::Exists($uninstall) -or + -not [IO.File]::Exists($state) -or -not (Test-Path -LiteralPath $uninstallKey)){ + throw 'R319_REAL_INSTALLED_FILES_OR_REGISTRATION_MISSING' + } + $installedMainHash=Hash $main + $installedUninstallHash=Hash $uninstall + if($installedMainHash -cne $candidate.MainSHA256 -or + $installedUninstallHash -cne $candidate.SetupSHA256){ + throw 'R319_EXACT_INSTALLED_MAIN_OR_UNINSTALL_SHA_MISMATCH' + } + $checks.Add('INSTALLED_MAIN_AND_UNINSTALL_MATCH_EXACT_PRIVATE_CANDIDATE') + $stateData=Get-Content -LiteralPath $state -Raw|ConvertFrom-Json + if($stateData.Version -cne '1.7.8' -or + [string]::IsNullOrWhiteSpace([string]$stateData.SensorLayerStatus)){ + throw 'R319_REAL_INSTALL_STATE_NOT_VERSION_BOUND' + } + $sensorStatus=[string]$stateData.SensorLayerStatus + $checks.Add('VERSION_AND_SENSOR_STATUS_PERSISTED_IN_REAL_INSTALL_STATE') + if(-not [IO.File]::Exists($startLink) -or -not [IO.File]::Exists($repairLink) -or + [IO.File]::Exists($desktopLink) -or [IO.File]::Exists($startupLink)){ + throw 'R319_EXPECTED_START_AND_REPAIR_SHORTCUTS_NOT_EXACT' + } + $checks.Add('START_AND_REPAIR_CREATED_DESKTOP_AND_STARTUP_DISABLED') + $shell=$null;$startShortcut=$null;$repairShortcut=$null + try{ + $shell=New-Object -ComObject WScript.Shell + $startShortcut=$shell.CreateShortcut($startLink) + $repairShortcut=$shell.CreateShortcut($repairLink) + if(-not [string]::Equals([string]$startShortcut.TargetPath,$main,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$repairShortcut.TargetPath,$uninstall,[StringComparison]::OrdinalIgnoreCase) -or + [string]$repairShortcut.Arguments -cne '/repair-sensors'){ + throw 'R319_ACTUAL_COM_SHORTCUT_TARGET_OR_ARGS_WRONG' + } + }finally{ + foreach($o in @($startShortcut,$repairShortcut,$shell)){ + if($null -ne $o -and [Runtime.InteropServices.Marshal]::IsComObject($o)){ + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($o) + } + } + } + $checks.Add('REAL_INSTALLED_COM_SHORTCUT_TARGET_AND_ARGS_READBACK') + $runBag=Get-ItemProperty -LiteralPath $runKey -ErrorAction SilentlyContinue + if($runBag -and $runBag.PSObject.Properties.Name -contains 'TaskbarMonitorEnhanced'){ + throw 'R319_DISABLED_STARTUP_REGISTRY_FLAG_IGNORED' + } + $checks.Add('DISABLED_AUTOSTART_REGISTRY_NOT_ENABLED') + $tempScripts=@(Get-ChildItem -LiteralPath $env:RUNNER_TEMP -File -Filter 'tbme_shortcut_*.ps1' -ErrorAction SilentlyContinue) + if($tempScripts.Count){throw 'R319_UNEXPECTED_SHORTCUT_EXECUTABLE_TEMP_SCRIPT'} + $checks.Add('NO_MUTABLE_SHORTCUT_POWERSHELL_SCRIPT_CREATED') + # Only terminate this disposable runner's exact product process, if the + # installer elected to launch one. Never terminate other process names. + foreach($proc in @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue)){ + try{ + $proc.Kill() + [void]$proc.WaitForExit(5000) + }finally{$proc.Dispose()} + } + $checks.Add('DISPOSABLE_OWN_PRODUCT_PROCESS_STOPPED_BEFORE_UNINSTALL') + $uninstallExit=ExecuteBounded $uninstall ([string[]]@('/uninstall','/quiet')) 210000 + if($uninstallExit -ne 0){throw ('R319_REAL_PRIVATE_UNINSTALL_EXIT_'+$uninstallExit)} + $checks.Add('REAL_PRIVATE_UNINSTALL_PROCESS_EXIT_ZERO') + $clock=[Diagnostics.Stopwatch]::StartNew() + while([IO.Directory]::Exists($app) -and $clock.Elapsed.TotalSeconds -lt 70){ + Start-Sleep -Milliseconds 350 + } + $cleanupSeconds=[int][Math]::Ceiling($clock.Elapsed.TotalSeconds) + if([IO.Directory]::Exists($app)){ + throw 'R319_REAL_DEFERRED_INMEMORY_CLEANUP_LEFT_APP_ROOT' + } + $checks.Add('DEFERRED_ENCODEDCOMMAND_CLEANUP_REMOVED_EXACT_APPROOT') + foreach($entry in @( + [pscustomobject]@{P=$uninstallKey;N='UNINSTALL_REGISTRY'}, + [pscustomobject]@{P=$startLink;N='START_LINK'}, + [pscustomobject]@{P=$repairLink;N='REPAIR_LINK'}, + [pscustomobject]@{P=$desktopLink;N='DESKTOP_LINK'}, + [pscustomobject]@{P=$startupLink;N='STARTUP_LINK'} + )){Absent $entry.P $entry.N} + $checks.Add('ACTUAL_SHORTCUTS_AND_UNINSTALL_REGISTRATION_REMOVED') + $afterRun=Get-ItemProperty -LiteralPath $runKey -ErrorAction SilentlyContinue + if($afterRun -and $afterRun.PSObject.Properties.Name -contains 'TaskbarMonitorEnhanced'){ + throw 'R319_RUN_VALUE_LEFT_AFTER_UNINSTALL' + } + $checks.Add('ACTUAL_RUN_REGISTRY_VALUE_ABSENT') + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R319_PROTECTED_SENSOR_TASK_LEFT_REGISTERED' + } + $checks.Add('ACTUAL_OWNED_PROTECTED_TASK_ABSENT_AFTER_UNINSTALL') + if([IO.Directory]::Exists($brokerRoot)){ + throw 'R319_OWNED_PROTECTED_BROKER_FILES_NOT_REMOVED' + } + $checks.Add('ACTUAL_PROTECTED_SENSOR_BROKER_FOLDER_ABSENT') + $checks.Add('NO_OWNER_PC_OR_PUBLIC_RELEASE_MUTATION') +}catch{ + $failure=$_.Exception.Message +}finally{ + $result=[ordered]@{ + Schema=1;Revision='R319';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Status=if(!$failure -and $checks.Count -eq 18){'PASS_REAL_PRIVATE_INSTALL_UNINSTALL_ON_DISPOSABLE_HOST'}else{'FAIL_CLOSED_DISPOSABLE_HOST_ONLY'} + PrivateCandidateOnly=$true;OriginalOwnerComputerTouched=$false + ActualWindowsHostedInstallExecuted=($installExit -eq 0) + ActualWindowsHostedUninstallExecuted=($uninstallExit -eq 0) + ActualProductUserRootRemoved=(-not [IO.Directory]::Exists($app)) + ActualProtectedTaskAbsent=(@(Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count -eq 0) + ActualProtectedBrokerAbsent=(-not [IO.Directory]::Exists($brokerRoot)) + ActualShortcutLinksRemoved=(-not [IO.File]::Exists($startLink) -and -not [IO.File]::Exists($repairLink)) + ConfirmedNoBundledPawnIODriverLaunched=$true + TrueUACPromptInteractionTested=$false + MainExpectedSHA256=$candidate.MainSHA256 + MainInstalledSHA256=$installedMainHash + SetupExpectedSHA256=$candidate.SetupSHA256 + UninstallerInstalledSHA256=$installedUninstallHash + SensorStatus=$sensorStatus + InstallExitCode=$installExit;UninstallExitCode=$uninstallExit + DeferredCleanupSeconds=$cleanupSeconds + Checks=@($checks) + Failure=$failure + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + 'R319_TEST_STATUS='+$result.Status+' FAILURE='+$failure +} +if($failure){throw ('R319_PRIVATE_INSTALLED_UNINSTALL_FAILED '+$failure)} +'R319_REAL_PRIVATE_178_HOSTED_INSTALL_AND_UNINSTALL_PASS' diff --git a/build/Test-R321-PinnedMainLaunch-Native.ps1 b/build/Test-R321-PinnedMainLaunch-Native.ps1 new file mode 100644 index 00000000..b0dc5a89 --- /dev/null +++ b/build/Test-R321-PinnedMainLaunch-Native.ps1 @@ -0,0 +1,162 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R321_ONLY_DISPOSABLE_GITHUB_WINDOWS_GUEST' +} +function SHA([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R321_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R302_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R321_EXISTING_TEST_FIXTURE'} +[void][IO.Directory]::CreateDirectory($root) +$old=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$prev=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $old.Main -or + $prev.Host -cne $HostLabel -or + $prev.SourceCommit -cne $env:GITHUB_SHA -or + $prev.Status -cne 'PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED'){ + throw 'R321_CANDIDATE_OR_PREVIOUS_NATIVE_PROOF_UNTRUSTED' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$method=$type.GetMethod('OpenPinnedVerifiedMainForLaunch',[Reflection.BindingFlags]'Static,NonPublic') +if(!$method){throw 'R321_COMPILED_SETUP_GUARD_NOT_FOUND'} +$srcRes=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$srcRes){throw 'R321_COMPILED_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($srcRes) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $embedded) -cne (& $normal ([IO.File]::ReadAllText($source)))){ + throw 'R321_PRIVATE_EXE_COMPILED_SOURCE_DIFFERS' +} +$code=[IO.File]::ReadAllText($source) +$start=$code.IndexOf('using(FileStream pinned=OpenPinnedVerifiedMainForLaunch(',[StringComparison]::Ordinal) +$builder=$code.IndexOf('BuildPinnedMainProcessInfo(pinned,AppRoot)',[StringComparison]::Ordinal) +$launch=$code.IndexOf('Process.Start(direct)',[StringComparison]::Ordinal) +$finish=$code.IndexOf('MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"', [StringComparison]::Ordinal) +if($start -lt 0 -or $builder -le $start -or $launch -le $builder -or + $finish -le $launch -or $code.Contains('Process.Start(AppExe)')){ + throw 'R321_R326_GUARD_NOT_BOUND_ACROSS_DIRECT_CREATEPROCESS' +} +$checks.Add('EXACT_EMBEDDED_SOURCE_AND_REAL_LAUNCH_GUARD_ORDER') +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') +if(!$resource){throw 'R321_TRUSTED_MAIN_RESOURCE_MISSING'} +$sourceStream=[IO.MemoryStream]::new() +try{$resource.CopyTo($sourceStream)}finally{$resource.Dispose()} +$bytes=$sourceStream.ToArray() +$sourceStream.Dispose() +$expected=[BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash($bytes))).Replace('-','') +if($expected.Length -ne 64 -or $bytes.Length -lt 1024){throw 'R321_PE_RESOURCE_HASH_OR_SIZE_BAD'} +$checks.Add('EXACT_PE_MAIN_SHA256_AND_SOURCE_LENGTH') +$folder=Join-Path $root 'safe' +[void][IO.Directory]::CreateDirectory($folder) +$file=Join-Path $folder 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($file,$bytes) +function Validate([string]$path,[string]$dir,[string]$expectedHash){ + try{return $method.Invoke($null,[object[]]@($path,$dir,$expectedHash))} + catch{throw [InvalidOperationException]::new($_.Exception.GetBaseException().Message,$_.Exception)} +} +function Reject([string]$label,[string]$path,[string]$dir,[string]$expectedHash,[string]$pattern){ + $refused=$false + try{ + $x=Validate $path $dir $expectedHash + if($null -ne $x){$x.Dispose()} + }catch{if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw}} + if(!$refused){throw ('R321_FAIL_OPEN_'+$label)} + $script:checks.Add($label) +} +$lock=[IO.FileStream](Validate $file $folder $expected) +if(!$lock.CanRead -or $lock.Length -ne $bytes.Length -or + -not [string]::Equals([IO.Path]::GetFullPath($lock.Name),[IO.Path]::GetFullPath($file),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R321_VALID_MAIN_LOCK_IS_NOT_PINNED' +} +$checks.Add('SHA_VERIFIED_MAIN_RETURNED_READABLE_PINNED_HANDLE') +$writeDenied=$false;$renameDenied=$false +try{ + try{ + $writer=[IO.FileStream]::new($file,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite) + $writer.Dispose() + }catch [IO.IOException]{$writeDenied=$true} + try{[IO.File]::Move($file,(Join-Path $folder 'wrong-name.exe'))} + catch [IO.IOException]{$renameDenied=$true} + if(-not $writeDenied -or -not $renameDenied){ + throw ('R321_OPEN_HANDLE_SHARING_DID_NOT_PROTECT_FILE WRITE='+$writeDenied+' RENAME='+$renameDenied) + } + if((SHA $file) -cne $expected){throw 'R321_PINNED_BYTES_CHANGED'} +}finally{$lock.Dispose()} +$checks.Add('FILE_WRITE_AND_RENAME_DENIED_DURING_PROCESS_START_WINDOW') +# These hostile test cases must all fail before any GUI/process is launched. +Reject 'WRONG_EMBEDDED_MAIN_HASH_REJECTED' $file $folder ('0'*64) 'does not match' +Reject 'UNRELATED_ROOT_REJECTED' $file (Join-Path $root 'foreign') $expected 'escaped' +Reject 'UNEXPECTED_EXE_BASENAME_REJECTED' (Join-Path $folder 'evil.exe') $folder $expected 'escaped' +Reject 'RELATIVE_MAIN_PATH_REJECTED' 'TaskbarMonitorEnhanced.exe' $folder $expected 'incomplete' +Reject 'UNC_INSTALL_ROOT_REJECTED' '\\server\share\TaskbarMonitorEnhanced.exe' '\\server\share' $expected 'escaped' +$altered=[byte[]]$bytes.Clone() +$altered[36]=($altered[36] -bxor 1) +[IO.File]::WriteAllBytes($file,$altered) +Reject 'SAME_NAME_BINARY_TAMPER_REJECTED' $file $folder $expected 'does not match' +[IO.File]::WriteAllBytes($file,$bytes) +if((SHA $file) -cne $expected){throw 'R321_TEST_RESTORE_CANDIDATE_FAILED'} +$checks.Add('TEST_ONLY_MAIN_RESOURCE_EXACTLY_RESTORED') +# A folder junction with a valid PE at the target cannot escape the pin. +$outside=Join-Path $root 'outside' +[void][IO.Directory]::CreateDirectory($outside) +$outerFile=Join-Path $outside 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($outerFile,$bytes) +$canary=Join-Path $outside 'unrelated-canary.txt' +[IO.File]::WriteAllText($canary,'R321_PRESERVE_UNRELATED',[Text.Encoding]::ASCII) +$canaryHash=SHA $canary +$link=Join-Path $root 'junction-root' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +try{ + Reject 'JUNCTION_ANCESTOR_REJECTED' (Join-Path $link 'TaskbarMonitorEnhanced.exe') $link $expected 'reparse' +}finally{[IO.Directory]::Delete($link)} +if((SHA $canary) -cne $canaryHash){throw 'R321_JUNCTION_EXTERNAL_CANARY_CHANGED'} +$checks.Add('JUNCTION_TARGET_FILE_AND_CANARY_PRESERVED') +if((SHA $main) -cne $old.Main -or (SHA $config) -cne $old.Config -or (SHA $state) -cne $old.State){ + throw 'R321_INSTALLED_GUEST_MAIN_OR_SETTINGS_CHANGED' +} +$checks.Add('CURRENT_GUEST_INSTALLATION_SETTINGS_UNCHANGED') +$record=[ordered]@{ + Schema=1;Revision='R321';Status='PASS_PINNED_MAIN_BEFORE_AUTOLAUNCH_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + ExactPEInstallerSourceVerified=$true;VerifiedMainSHA256=$expected + ReadHandleHeldAcrossLaunchPath=$true + ExclusiveWriterDenied=$writeDenied + RenameBeforeLaunchDenied=$renameDenied + AlteredSameNameBinaryRejected=$true + JunctionAndUnsafePathsRejected=$true + MainWasNotActuallyLaunchedInFixture=$true + OriginalOwnerHostTouched=$false;ActualProtectedTaskTouched=$false + ActualUACInvoked=$false;SecurityCodeQLAccepted=$false + GuestMainAndUserSettingsPreserved=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R321_'+$HostLabel+'_PINNED_MAIN_HASH_AND_SHARE_BOUNDARY_PASS' diff --git a/build/Test-R326-DirectMainProcess-Native.ps1 b/build/Test-R326-DirectMainProcess-Native.ps1 new file mode 100644 index 00000000..df57c200 --- /dev/null +++ b/build/Test-R326-DirectMainProcess-Native.ps1 @@ -0,0 +1,128 @@ +[CmdletBinding()] +param([string]$SetupPath='') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R326_REAL_COMPILED_DOTNET_FRAMEWORK_SETUP_REQUIRED' +} +$setup=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$type=$setup.GetType('SetupProgram',$true) +$pinMethod=$type.GetMethod('OpenPinnedVerifiedMainForLaunch',[Reflection.BindingFlags]'NonPublic,Static') +$startMethod=$type.GetMethod('BuildPinnedMainProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') +if($null -eq $pinMethod -or $null -eq $startMethod){ + throw 'R326_DIRECT_MAIN_METHODS_MISSING_IN_REAL_PE' +} +$srcResource=$setup.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if($null -eq $srcResource){throw 'R326_CANONICAL_SOURCE_EMBEDDED_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($srcResource) +try{$embeddedSource=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs'))) +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $source) -cne (& $normalize $embeddedSource)){ + throw 'R326_CANONICAL_SOURCE_NOT_EQUAL_PE_EMBED' +} +$checks=[Collections.Generic.List[string]]::new() +$checks.Add('EXACT_PE_SETUP_EMBEDS_CURRENT_SOURCE') +$posPin=$source.IndexOf('using(FileStream pinned=OpenPinnedVerifiedMainForLaunch(',[StringComparison]::Ordinal) +$posStartInfo=$source.IndexOf('BuildPinnedMainProcessInfo(pinned,AppRoot)',[StringComparison]::Ordinal) +$posStart=$source.IndexOf('Process.Start(direct)',[StringComparison]::Ordinal) +$posSuccess=$source.IndexOf('MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"',[StringComparison]::Ordinal) +if($posPin -lt 0 -or $posStartInfo -le $posPin -or $posStart -le $posStartInfo -or $posSuccess -le $posStart -or + $source.Contains('Process.Start(AppExe)')){ + throw 'R326_SHA_PIN_DIRECT_CREATEPROCESS_ORDER_INVALID' +} +$checks.Add('SHA_PINNED_MAIN_HANDLE_HELD_ACROSS_DIRECT_PROCESS_START') +$resource=$setup.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') +if(!$resource){throw 'R326_EMBEDDED_MAIN_NOT_FOUND'} +$memory=[IO.MemoryStream]::new() +try{$resource.CopyTo($memory)}finally{$resource.Dispose()} +$mainBytes=$memory.ToArray() +$memory.Dispose() +$sha=[Security.Cryptography.SHA256]::Create() +try{$expected=([BitConverter]::ToString($sha.ComputeHash($mainBytes))).Replace('-','')} +finally{$sha.Dispose()} +if($mainBytes.Length -lt 1024 -or $expected.Length -ne 64){throw 'R326_MAIN_RESOURCE_INVALID'} +$checks.Add('EMBEDDED_MAIN_EXPECTED_SHA_IS_FULL_HEX') + +$dir=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r326_direct_main_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($dir)|Out-Null +$root=Join-Path $dir 'literal & dollar $name with spaces' +[IO.Directory]::CreateDirectory($root)|Out-Null +$file=Join-Path $root 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($file,$mainBytes) +$pin=$null +function Invoke-Builder([object]$handle,[string]$expectedRoot){ + $args=New-Object 'object[]' 2 + $args[0]=$handle + $args[1]=$expectedRoot + return $startMethod.Invoke($null,$args) +} +function Expect-Refusal([string]$label,[object]$handle,[string]$expectedRoot,[string]$message){ + $refused=$false + try{ + $value=Invoke-Builder $handle $expectedRoot + if($null -ne $value){throw ('R326_BAD_BUILDER_ACCEPTED_'+$label)} + }catch{ + $failure=$_.Exception + while($null -ne $failure.InnerException){$failure=$failure.InnerException} + if($failure.Message -match $message){$refused=$true} + else{throw} + } + if(!$refused){throw ('R326_NEGATIVE_WAS_NOT_REFUSED_'+$label)} + $script:checks.Add($label) +} +try{ + $args=New-Object 'object[]' 3 + $args[0]=[string]$file;$args[1]=[string]$root;$args[2]=[string]$expected + $pin=[IO.FileStream]$pinMethod.Invoke($null,$args) + if($null -eq $pin -or !$pin.CanRead -or + ![string]::Equals([IO.Path]::GetFullPath($pin.Name),[IO.Path]::GetFullPath($file),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R326_EXACT_SHA_FILESTREAM_NOT_PINNED' + } + $checks.Add('READ_ONLY_EXACT_SHA_MAIN_HANDLE_PINNED') + $built=Invoke-Builder $pin $root + if($built.UseShellExecute -ne $false){throw 'R326_SHELLEXECUTE_NOT_DISABLED'} + $checks.Add('REAL_PROCESSSTARTINFO_USE_SHELL_EXECUTE_FALSE') + if($built.FileName -cne [IO.Path]::GetFullPath($file)){ + throw 'R326_DIRECT_EXECUTABLE_NOT_EXACT_FILESTREAM_PATH' + } + $checks.Add('REAL_PROCESSSTARTINFO_EXE_EXACT_PINNED_FILE') + if($built.Arguments -cne ''){throw 'R326_MAIN_RECEIVES_UNTRUSTED_ARGS'} + $checks.Add('REAL_PROCESSSTARTINFO_ARGUMENTS_EMPTY') + if($built.Verb -cne '' -or $built.ErrorDialog -ne $false){ + throw 'R326_SHELL_VERB_OR_ERROR_DIALOG_PRESENT' + } + $checks.Add('REAL_PROCESSSTARTINFO_HAS_NO_SHELL_VERB') + if($built.WorkingDirectory -cne ''){ + throw 'R326_WORKDIR_SHOULD_NOT_CONSUME_UNTRUSTED_PROFILE_PATH' + } + $checks.Add('REAL_PROCESSSTARTINFO_INHERITS_PREVIOUS_CWD') + if(!$built.FileName.Contains('&') -or !$built.FileName.Contains('$name')){ + throw 'R326_META_CHARACTERS_LOST_OR_INTERPRETED' + } + $checks.Add('METACHARACTER_PATH_PRESERVED_AS_LITERAL_WITHOUT_SHELL') + $writeBlocked=$false + try{$writer=[IO.FileStream]::new($file,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite);$writer.Dispose()} + catch [IO.IOException]{$writeBlocked=$true} + if(!$writeBlocked){throw 'R326_PINNED_HANDLE_DID_NOT_DENY_WRITER'} + $checks.Add('WRITE_DENIED_WHILE_STARTINFO_PREPARED') + Expect-Refusal 'FOREIGN_INSTALLATION_ROOT_REFUSED' $pin (Join-Path $dir 'foreign') 'escaped' + Expect-Refusal 'RELATIVE_INSTALLATION_ROOT_REFUSED' $pin 'relative' 'unavailable' + Expect-Refusal 'NULL_MAIN_HANDLE_REFUSED' $null $root 'unavailable' + $pin.Dispose();$pin=$null + $closed=[IO.FileStream]::new($file,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $closed.Dispose() + Expect-Refusal 'DISPOSED_MAIN_HANDLE_REFUSED' $closed $root 'unavailable' + if($checks.Count -ne 15){throw ('R326_TEST_COUNT_MISMATCH_'+$checks.Count)} + Write-Output ('R326_DIRECT_MAIN_WINDOWS_NET48_PASS='+$checks.Count+'/15') + foreach($item in $checks){Write-Output ('PASS '+$item)} +}finally{ + if($pin){$pin.Dispose()} + if([IO.Directory]::Exists($dir) -and [IO.Path]::GetFileName($dir).StartsWith('tbme_r326_direct_main_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($dir,$true) + } +} diff --git a/build/Test-R327-CanonicalSensorTemp-Native.ps1 b/build/Test-R327-CanonicalSensorTemp-Native.ps1 new file mode 100644 index 00000000..c74f0793 --- /dev/null +++ b/build/Test-R327-CanonicalSensorTemp-Native.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param([string]$SetupPath='') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R327_COMPILED_NET_FRAMEWORK_SETUP_REQUIRED' +} +$setup=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$type=$setup.GetType('SetupProgram',$true) +$canonical=$type.GetMethod('CanonicalSensorTempFromKnownFolders', + [Reflection.BindingFlags]'NonPublic,Static') +$create=$type.GetMethod('CreatePrivateSensorPayloadDirectory', + [Reflection.BindingFlags]'NonPublic,Static') +if(!$canonical -or !$create){throw 'R327_EXACT_PE_MISSING_CANONICAL_PAYLOAD_METHODS'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$label){ + $script:checks.Add($label) + Write-Output ('PASS '+$label) +} +function Invoke-Canonical([string]$local,[string]$profile){ + $args=New-Object 'object[]' 2 + $args[0]=[string]$local;$args[1]=[string]$profile + return [string]$canonical.Invoke($null,$args) +} +function Expect-BoundaryRefusal([string]$label,[string]$local,[string]$profile){ + $actual='' + try{ + $returned=Invoke-Canonical $local $profile + throw ('R327_UNEXPECTEDLY_ACCEPTED_'+$label+' '+$returned) + }catch{ + $failure=$_.Exception + while($null -ne $failure.InnerException){$failure=$failure.InnerException} + $actual=$failure.Message + } + if($actual -match '^R327_UNEXPECTEDLY_ACCEPTED_'){throw $actual} + if($actual -notmatch '(?i)reparse|canonical|unavailable|outside|root|path'){ + throw ('R327_REJECTED_FOR_WRONG_REASON_'+$label+' '+$actual) + } + Pass $label +} +function Invoke-Create([string]$prefix){ + $args=New-Object 'object[]' 1 + $args[0]=[string]$prefix + return [string]$create.Invoke($null,$args) +} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$resource=$setup.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R327_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embeddedSource=$reader.ReadToEnd()}finally{$reader.Dispose()} +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $source) -cne (& $normalize $embeddedSource)){throw 'R327_PE_DOES_NOT_EMBED_THIS_SOURCE'} +Pass 'EXACT_COMPILED_PE_EMBEDS_SOURCE' + +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$expectedReal=[IO.Path]::GetFullPath((Join-Path $local 'Temp')) +$result=Invoke-Canonical $local $profile +if(![string]::Equals($result,$expectedReal,[StringComparison]::OrdinalIgnoreCase)){ + throw ('R327_ACTUAL_PROFILE_TEMP_MISMATCH_'+$result) +} +Pass 'REAL_OS_PROFILE_CANONICAL_TEMP' + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r327_sensor_temp_'+[Guid]::NewGuid().ToString('N')) +[void][IO.Directory]::CreateDirectory($base) +$created=@() +$originalTemp=$env:TEMP +$originalTmp=$env:TMP +try{ + $fakeProfile=Join-Path $base 'literal & special $profile' + $fakeLocal=Join-Path $fakeProfile 'AppData\Local' + [void][IO.Directory]::CreateDirectory($fakeLocal) + $expected=Join-Path $fakeLocal 'Temp' + $actual=Invoke-Canonical $fakeLocal $fakeProfile + if(![string]::Equals($actual,$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R327_SYNTHETIC_PROFILE_NOT_CANONICAL' + } + Pass 'SYNTHETIC_PROFILE_WITH_METACHARACTERS' + + Expect-BoundaryRefusal 'MISMATCHED_LOCAL_PROFILE' (Join-Path $base 'wrong\AppData\Local') $fakeProfile + Expect-BoundaryRefusal 'RELATIVE_KNOWN_LOCAL_REFUSED' 'AppData\Local' $fakeProfile + Expect-BoundaryRefusal 'UNC_PROFILE_REFUSED' '\\attacker.invalid\share\AppData\Local' '\\attacker.invalid\share' + $drive=[IO.Path]::GetPathRoot([IO.Path]::GetFullPath($base)) + Expect-BoundaryRefusal 'DRIVE_ROOT_ONLY_PROFILE_REFUSED' (Join-Path $drive 'AppData\Local') $drive + + $external=Join-Path $base 'external-canary-target' + [void][IO.Directory]::CreateDirectory($external) + $sentinel=Join-Path $external 'do_not_touch.txt' + [IO.File]::WriteAllText($sentinel,'R327_UNTOUCHED') + + $link=Join-Path $fakeLocal 'Temp' + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + try{ + Expect-BoundaryRefusal 'TEMP_JUNCTION_REFUSED' $fakeLocal $fakeProfile + if([IO.File]::ReadAllText($sentinel) -cne 'R327_UNTOUCHED'){ + throw 'R327_FOREIGN_CONTENT_CHANGED_AFTER_TEMP_REFUSAL' + } + }finally{ + if([IO.Directory]::Exists($link)){[IO.Directory]::Delete($link)} + } + $otherProfile=Join-Path $base 'otherProfile' + [void][IO.Directory]::CreateDirectory((Join-Path $otherProfile 'AppData')) + $otherLocal=Join-Path $otherProfile 'AppData\Local' + $link2=New-Item -ItemType Junction -Path $otherLocal -Target $external -ErrorAction Stop + try{ + Expect-BoundaryRefusal 'LOCAL_JUNCTION_REFUSED' $otherLocal $otherProfile + if([IO.File]::ReadAllText($sentinel) -cne 'R327_UNTOUCHED'){ + throw 'R327_FOREIGN_CONTENT_CHANGED_AFTER_LOCAL_REFUSAL' + } + }finally{ + if([IO.Directory]::Exists($otherLocal)){[IO.Directory]::Delete($otherLocal)} + } + + $refused=$false + try{$invalid=Invoke-Create 'arbitrary_'}catch{ + $failure=$_.Exception + while($null -ne $failure.InnerException){$failure=$failure.InnerException} + if($failure.Message -match 'Unexpected private sensor payload prefix'){ + $refused=$true + }else{throw} + } + if(!$refused){throw 'R327_BAD_PAYLOAD_PREFIX_ACCEPTED'} + Pass 'UNRECOGNIZED_PREFIX_REFUSED' + + $poison=Join-Path $base 'unrelated-env-TEMP' + [void][IO.Directory]::CreateDirectory($poison) + $env:TEMP=$poison + $env:TMP=$poison + foreach($prefix in @('tbme_setup_','tbme_uninstall_')){ + $payload=Invoke-Create $prefix + if(![string]::Equals([IO.Path]::GetDirectoryName($payload), + $expectedReal,[StringComparison]::OrdinalIgnoreCase)){ + throw ('R327_TAINTED_TEMP_ENV_SELECTED_'+$payload) + } + if(![IO.Directory]::Exists($payload) -or + [IO.Path]::GetFileName($payload) -cnotmatch ('^'+$prefix+'[0-9a-f]{32}$')){ + throw 'R327_NOT_A_RANDOM_PRIVATE_PAYLOAD' + } + if(@([IO.Directory]::GetFileSystemEntries($payload)).Count -ne 0){ + throw 'R327_GENERATED_PAYLOAD_NOT_EMPTY' + } + $created+=,$payload + Pass ('RANDOM_'+$prefix+'PAYLOAD_UNDER_REAL_PROFILE') + } + if(@([IO.Directory]::GetFileSystemEntries($poison)).Count -ne 0){ + throw 'R327_ENV_POISON_DIRECTORY_TOUCHED' + } + Pass 'POISONED_TEMP_TMP_ENV_NOT_USED' + + if($source.Contains('Path.GetTempPath()') -or + !$source.Contains('CreatePrivateSensorPayloadDirectory("tbme_setup_")') -or + !$source.Contains('CreatePrivateSensorPayloadDirectory("tbme_uninstall_")')){ + throw 'R327_INSTALL_OR_UNINSTALL_BYPASSES_CANONICAL_PAYLOAD_DIRECTORY' + } + Pass 'INSTALL_AND_UNINSTALL_USE_ONE_CANONICAL_PATH' + if($checks.Count -ne 14){throw ('R327_TEST_COUNT_MISMATCH_'+$checks.Count)} + Write-Output ('R327_CANONICAL_SENSOR_TEMP_NET48_PASS='+$checks.Count+'/14') +}finally{ + $env:TEMP=$originalTemp + $env:TMP=$originalTmp + foreach($p in $created){ + if([IO.Directory]::Exists($p) -and + [IO.Path]::GetFileName($p) -match '^tbme_(setup|uninstall)_[0-9a-f]{32}$' -and + [IO.Path]::GetDirectoryName($p) -eq $expectedReal -and + @([IO.Directory]::GetFileSystemEntries($p)).Count -eq 0){ + [IO.Directory]::Delete($p) + } + } + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r327_sensor_temp_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-R328-HelperLogBoundary-Native.ps1 b/build/Test-R328-HelperLogBoundary-Native.ps1 new file mode 100644 index 00000000..5e412b28 --- /dev/null +++ b/build/Test-R328-HelperLogBoundary-Native.ps1 @@ -0,0 +1,169 @@ +[CmdletBinding()] +param([string]$SetupPath='') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R328_EXACT_COMPILED_FRAMEWORK_SETUP_REQUIRED' +} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$embeddedAssembly=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$stream=$embeddedAssembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R328_HELPER_PE_RESOURCE_NOT_FOUND'} +$reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText($helper) +$normalized={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalized $embedded) -cne (& $normalized $source)){throw 'R328_HELPER_PE_RESOURCE_NOT_IDENTICAL'} +$script:checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +Pass 'EXACT_SETUP_PE_EMBEDS_TRUSTED_HELPER_SOURCE' +$tokens=$null;$parseErrors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$parseErrors) +if(@($parseErrors).Count -ne 0){throw 'R328_TRUSTED_HELPER_AST_PARSE_ERRORS'} +$native=[regex]::Matches($source,'(?s)Add-Type -TypeDefinition @''\r?\n(.*?)\r?\n''@') +if($native.Count -ne 1){throw 'R335_NATIVE_CLASS_SOURCE_NOT_UNIQUE'} +Add-Type -TypeDefinition $native[0].Groups[1].Value +if($null -eq ('TBMEScopedUserLogLease' -as [type])){throw 'R335_EXPECTED_PRODUCTION_CLASS_NOT_COMPILED'} +foreach($fn in @('Assert-SensorBoundaryNoReparse','Assert-SensorUserLogSink','Write-Log','Write-Result')){ + $definition=@($ast.FindAll({param($x) $x -is [Management.Automation.Language.FunctionDefinitionAst]},$true)| + Where-Object Name -EQ $fn) + if($definition.Count -ne 1){throw ('R328_FUNCTION_NOT_EXACTLY_ONE_'+$fn)} + . ([scriptblock]::Create($definition[0].Extent.Text)) +} +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r328_logs_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$profile=Join-Path $base 'isolated profile with & $special' +$AppRoot=Join-Path $profile 'AppData\Local\TaskbarMonitorEnhanced' +[IO.Directory]::CreateDirectory($AppRoot)|Out-Null +$script:ValidatedSensorProfileRoot=$profile +$LogsRoot=Join-Path $AppRoot 'Logs' +[IO.Directory]::CreateDirectory($LogsRoot)|Out-Null +$ResultPath=Join-Path $LogsRoot 'sensor_install_result.json' +$LogPath=Join-Path $LogsRoot 'sensor_install.log' +$external=Join-Path $base 'external-canary-data' +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'NEVER_TOUCH.txt' +[IO.File]::WriteAllText($canary,'R328_CANARY_UNCHANGED') +$SupervisorHealthy=$false +$CpuTransportHealthy=$false +$GpuTransportHealthy=$false +$StorageTransportHealthy=$false +$CpuDataAvailable=$false +$RollbackPerformed=$false +$RollbackSucceeded=$false +$PreviousSensorLayerPresent=$false +$ActiveArchitecture='R328_ISOLATED_NATIVE_TEST_ONLY' + +function Expect-Refusal([string]$name,[scriptblock]$action,[string]$reason){ + $text='' + try{ + & $action|Out-Null + throw ('R328_FAIL_OPEN_'+$name) + }catch{ + $errorObject=$_.Exception + while($null -ne $errorObject.InnerException){$errorObject=$errorObject.InnerException} + $text=$errorObject.Message + } + if($text -match '^R328_FAIL_OPEN_' -or $text -notmatch $reason){ + throw ('R328_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$name+' ACTUAL='+$text) + } + Pass $name +} +$logJunction=$null +$appJunction=$null +try{ + Write-Log 'NATIVE_SAFE_LOG' + if(!(Test-Path -LiteralPath $LogPath -PathType Leaf) -or + (Get-Content -LiteralPath $LogPath -Raw) -notmatch 'NATIVE_SAFE_LOG'){ + throw 'R328_SAFE_LOG_WRITE_FAILED' + } + Pass 'ORDINARY_USER_PROFILE_LOG_WORKS' + + Write-Result 'DEGRADED' 'Native no UAC mutation test' 'NOT_STARTED' $null $false $false $false + $result=Get-Content -LiteralPath $ResultPath -Raw |ConvertFrom-Json + if($result.Status -cne 'DEGRADED' -or $result.SensorHealthy -ne $false -or + $result.LogPath -cne $LogPath){throw 'R328_SAFE_RESULT_JSON_CONTRACT_FAILED'} + Pass 'ORDINARY_RESULT_JSON_CONTRACT_PRESERVED' + + Expect-Refusal 'WRONG_SINK_NAME_REFUSED' { + Assert-SensorUserLogSink (Join-Path $base 'foreign.txt') + } 'SENSOR_LOG_BOUNDARY_CHANGED_TARGET' + + $oldRoot=$LogsRoot + $LogsRoot=Join-Path $base 'wrong-logs-root' + try{Expect-Refusal 'MUTATED_LOGS_ROOT_VARIABLE_REFUSED' { Write-Log 'NEVER_WRITE' } 'SENSOR_LOG_BOUNDARY_CHANGED_ROOT'} + finally{$LogsRoot=$oldRoot} + + $moved=Join-Path $AppRoot 'LogsMovedBeforeWrite' + [IO.Directory]::Move($LogsRoot,$moved) + try{ + Expect-Refusal 'MISSING_LOG_FOLDER_REFUSED' { Write-Log 'NEVER_WRITE' } 'SENSOR_LOG_BOUNDARY_MISSING_LOG_DIRECTORY' + }finally{[IO.Directory]::Move($moved,$LogsRoot)} + + $moved=Join-Path $AppRoot 'LogsMovedForReparse' + [IO.Directory]::Move($LogsRoot,$moved) + New-Item -ItemType Junction -Path $LogsRoot -Target $external -ErrorAction Stop|Out-Null + $logJunction=$LogsRoot + Expect-Refusal 'POST_PREFLIGHT_LOG_JUNCTION_REFUSED' { + Write-Log 'FOREIGN_MODIFICATION_ATTEMPT' + } 'reparse' + Expect-Refusal 'POST_PREFLIGHT_RESULT_JUNCTION_REFUSED' { + Write-Result 'READY' 'FOREIGN_MODIFICATION_ATTEMPT' 'NOT_STARTED' $null $false $false $true + } 'reparse' + if([IO.File]::ReadAllText($canary) -cne 'R328_CANARY_UNCHANGED' -or + @(Get-ChildItem -LiteralPath $external -File).Count -ne 1){ + throw 'R328_EXTERNAL_CANARY_OR_DIRECTORY_WAS_MODIFIED' + } + Pass 'JUNCTION_EXTERNAL_CANARY_AND_CONTENT_PRESERVED' + [IO.Directory]::Delete($logJunction) + $logJunction=$null + [IO.Directory]::Move($moved,$LogsRoot) + + $retired=Join-Path $base 'AppRootRetiredBeforeWrite' + [IO.Directory]::Move($AppRoot,$retired) + New-Item -ItemType Junction -Path $AppRoot -Target $external -ErrorAction Stop | Out-Null + $appJunction=$AppRoot + Expect-Refusal 'POST_PREFLIGHT_APPROOT_JUNCTION_REFUSED' { + Write-Log 'FOREIGN_ROOT_MODIFICATION' + } 'reparse' + [IO.Directory]::Delete($appJunction) + $appJunction=$null + [IO.Directory]::Move($retired,$AppRoot) + + $pre=$source.IndexOf('Assert-SensorBoundaryNoReparse $LogsRoot',[StringComparison]::Ordinal) + $create=$source.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) + $post=$source.IndexOf('Assert-SensorBoundaryNoReparse $LogsRoot',$create+1) + $functionStart=$source.IndexOf('function Write-Log(',[StringComparison]::Ordinal) + if($pre -lt 0 -or $create -le $pre -or $post -le $create -or $functionStart -le $post){ + throw 'R328_CREATION_PATH_PRE_AND_POST_CHECK_ORDER_BROKEN' + } + Pass 'LOG_DIRECTORY_CREATION_PRE_AND_POST_BOUNDARY_ORDER' + + $scriptLines=$source + if($scriptLines.IndexOf('Assert-SensorUserLogSink $LogPath',[StringComparison]::Ordinal) -lt 0 -or + $scriptLines.IndexOf('Assert-SensorUserLogSink $ResultPath',[StringComparison]::Ordinal) -lt 0){ + throw 'R328_BOTH_WRITE_SINKS_ARE_NOT_GUARDED' + } + Pass 'BOTH_PRIVILEGED_USER_LOG_WRITES_HAVE_PATH_GUARDS' + + if($checks.Count -ne 12){throw ('R328_CHECK_COUNT_MISMATCH_'+$checks.Count)} + Write-Output ('R328_PRIVILEGED_HELPER_LOG_BOUNDARY_NET48_PASS='+$checks.Count+'/12') +}finally{ + if($logJunction -and [IO.Directory]::Exists($logJunction)){ + [IO.Directory]::Delete($logJunction) + } + if($appJunction -and [IO.Directory]::Exists($appJunction)){ + [IO.Directory]::Delete($appJunction) + } + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r328_logs_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-R329-PinnedMainAncestorRename-Native.ps1 b/build/Test-R329-PinnedMainAncestorRename-Native.ps1 new file mode 100644 index 00000000..0c5ca37d --- /dev/null +++ b/build/Test-R329-PinnedMainAncestorRename-Native.ps1 @@ -0,0 +1,209 @@ +[CmdletBinding()] +param([string]$SetupPath='', [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R329_REQUIRES_COMPILED_SETUP_NETFRAMEWORK_ON_WINDOWS' +} +$assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$type=$assembly.GetType('SetupProgram',$true) +$pinMethod=$type.GetMethod('OpenPinnedVerifiedMainForLaunch',[Reflection.BindingFlags]'NonPublic,Static') +$psiMethod=$type.GetMethod('BuildPinnedMainProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') +if(!$pinMethod -or !$psiMethod){throw 'R329_EXPECTED_REAL_PINNED_PROCESS_METHODS_MISSING'} +$sourceStream=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$sourceStream){throw 'R329_SOURCE_EMBEDDED_RESOURCE_MISSING'} +$sourceReader=[IO.StreamReader]::new($sourceStream) +try{$embedded=$sourceReader.ReadToEnd()}finally{$sourceReader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$norm={param($s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm $source)){throw 'R329_EMBEDDED_SOURCE_DIFFERS_FROM_REAL_WORKTREE'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +Pass 'COMPILED_SETUP_EXACT_EMBEDDED_SOURCE' +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') +if(!$resource){throw 'R329_EMBEDDED_MAIN_RESOURCE_MISSING'} +$memory=[IO.MemoryStream]::new() +try{$resource.CopyTo($memory)}finally{$resource.Dispose()} +$bytes=$memory.ToArray() +$memory.Dispose() +$sha=[Security.Cryptography.SHA256]::Create() +try{$expected=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','')} +finally{$sha.Dispose()} +if($bytes.Length -lt 1024 -or $expected.Length -ne 64){throw 'R329_BAD_IMMUTABLE_MAIN_RESOURCE'} +Pass 'EMBEDDED_MAIN_SHA256_PIN_AVAILABLE' + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r329_real_setup_main_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$profile=Join-Path $base 'user profile & literal $dollar' +$appData=Join-Path $profile 'AppData' +$local=Join-Path $appData 'Local' +$appRoot=Join-Path $local 'TaskbarMonitorEnhanced' +[IO.Directory]::CreateDirectory($appRoot)|Out-Null +$main=Join-Path $appRoot 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($main,$bytes) +$external=Join-Path $base 'outside-app' +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'external-canary.txt' +[IO.File]::WriteAllText($canary,'NO_EXTERNAL_MODIFICATION') +$actor=Join-Path $base 'move-test-actor.ps1' +$actorSource=@' +param([Parameter(Mandatory=$true)][string]$PathBase64) +$ErrorActionPreference='Stop' +$target=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($PathBase64)) +$moved=$target+'_actor_moved' +try { + [IO.Directory]::Move($target,$moved) + [IO.Directory]::Move($moved,$target) + Write-Output 'ACTOR_RENAME_ALLOWED_AND_RESTORED' + exit 61 +}catch [IO.IOException]{ + Write-Output 'ACTOR_RENAME_BLOCKED_IO' + exit 0 +}catch [UnauthorizedAccessException]{ + Write-Output 'ACTOR_RENAME_BLOCKED_ACCESS' + exit 0 +}catch{ + Write-Output ('ACTOR_UNEXPECTED_ERROR_'+$_.Exception.GetType().Name) + exit 70 +} +'@ +[IO.File]::WriteAllText($actor,$actorSource,[Text.UTF8Encoding]::new($false)) +$win=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +function Try-Rename([string]$path){ + $moved=$path+'_native_moved' + $ok=$false + try{[IO.Directory]::Move($path,$moved);$ok=$true} + catch [IO.IOException]{}catch [UnauthorizedAccessException]{} + if($ok){ + [IO.Directory]::Move($moved,$path) + return $true + } + return $false +} +function Invoke-SeparateActor([string]$target) { + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($target)) + $out=@(& $win -NoProfile -NonInteractive -File $actor -PathBase64 $encoded 2>&1) + $code=$LASTEXITCODE + if($code -notin @(0,61)){ + throw ('R329_CHILD_ACTOR_ERROR_EXIT_'+$code+' '+($out -join ' ')) + } + if($code -eq 61 -and @($out|Where-Object {$_ -eq 'ACTOR_RENAME_ALLOWED_AND_RESTORED'}).Count -ne 1){ + throw 'R329_CHILD_RENAME_ALLOWED_WITHOUT_RESTORE_EVIDENCE' + } + return $code +} +$pinned=$null +try { + foreach($pair in @(@('APPROOT',$appRoot),@('LOCAL',$local),@('APPDATA',$appData),@('PROFILE',$profile))){ + $success=$false + for($try=0;$try -lt 5 -and !$success;$try++){ + $success=Try-Rename $pair[1] + if(!$success){Start-Sleep -Milliseconds 70} + } + if(!$success){throw ('R329_UNPINNED_DIRECTORY_NOT_RENAMEABLE_'+$pair[0])} + Pass ('BASELINE_RENAME_ALLOWED_'+$pair[0]) + } + if((Invoke-SeparateActor $local) -ne 61){throw 'R329_CROSS_PROCESS_RENAME_WAS_NOT_POSSIBLE_WITHOUT_PIN'} + Pass 'BASELINE_SEPARATE_PROCESS_ANCESTOR_RENAME_ALLOWED' + $args=New-Object 'object[]' 3 + $args[0]=[string]$main + $args[1]=[string]$appRoot + $args[2]=[string]$expected + $pinned=[IO.FileStream]$pinMethod.Invoke($null,$args) + if(!$pinned.CanRead -or + ![string]::Equals([IO.Path]::GetFullPath($pinned.Name), + [IO.Path]::GetFullPath($main),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R329_REAL_SETUP_DID_NOT_PIN_EXPECTED_MAIN_FILE' + } + Pass 'PRODUCTION_METHOD_SHA_PINS_REAL_EMBEDDED_MAIN' + foreach($pair in @(@('APPROOT',$appRoot),@('LOCAL',$local),@('APPDATA',$appData),@('PROFILE',$profile))){ + if(Try-Rename $pair[1]){throw ('R329_ANCESTOR_RENAME_SUCCEEDED_WHILE_PINNED_'+$pair[0])} + Pass ('PINNED_MAIN_BLOCKS_RENAME_'+$pair[0]) + } + if((Invoke-SeparateActor $local) -ne 0){ + throw 'R329_SEPARATE_PROCESS_CAN_RENAME_PINNED_MAIN_ANCESTOR' + } + Pass 'PINNED_MAIN_BLOCKS_SEPARATE_PROCESS_LOCAL_RENAME' + $replacement=$false + try{$fs=[IO.FileStream]::new($main,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite);$fs.Dispose()} + catch [IO.IOException]{$replacement=$true} + if(!$replacement){throw 'R329_SECOND_FILE_WRITER_NOT_BLOCKED'} + Pass 'PINNED_MAIN_BLOCKS_SAME_NAME_WRITER' + $args2=New-Object 'object[]' 2 + $args2[0]=$pinned.PSObject.BaseObject + $args2[1]=[string]$appRoot + $processInfo=$psiMethod.Invoke($null,$args2) + if($processInfo.UseShellExecute -ne $false -or $processInfo.Arguments -cne '' -or + $processInfo.FileName -cne [IO.Path]::GetFullPath($main)){ + throw 'R329_PINNED_PROCESSSTARTINFO_IDENTITY_CHANGED' + } + Pass 'PROCESS_STARTINFO_IS_DIRECT_NO_SHELL_NO_ARGS' + $pinned.Position=0 + $checkSha=[Security.Cryptography.SHA256]::Create() + try {$actual=([BitConverter]::ToString($checkSha.ComputeHash($pinned))).Replace('-','')} + finally {$checkSha.Dispose()} + if($actual -cne $expected){throw 'R329_PINNED_FILE_BYTES_CHANGED'} + Pass 'OPEN_FILE_BYTES_STILL_EQUAL_IMMUTABLE_PE_HASH' + if([IO.File]::ReadAllText($canary) -cne 'NO_EXTERNAL_MODIFICATION'){ + throw 'R329_EXTERNAL_SENTINEL_CHANGED' + } + Pass 'EXTERNAL_CANARY_UNMODIFIED' +}finally{if($pinned){$pinned.Dispose()}} +try { + if(! (Try-Rename $local)){throw 'R329_LOCAL_RENAME_REMAINS_BLOCKED_AFTER_DISPOSAL'} + Pass 'LOCAL_RENAME_RESTORED_AFTER_PIN_RELEASE' + $actualSha=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + if($actualSha -cne $expected){throw 'R329_SANDBOX_FILE_ON_DISK_CHANGED'} + Pass 'ON_DISK_SHA_STILL_EQUALS_EMBEDDED_MAIN' + if($checks.Count -ne 19){throw ('R329_ACCEPTANCE_CHECK_COUNT_'+$checks.Count)} + if($HostLabel -cne 'LOCAL'){ + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R329_HOSTED_EVIDENCE_NOT_ON_DISPOSABLE_GITHUB_GUEST' + } + $dir=Join-Path $env:RUNNER_TEMP ('R329_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if([IO.Directory]::Exists($dir)){throw 'R329_HOSTED_WITNESS_DIRECTORY_ALREADY_PRESENT'} + [IO.Directory]::CreateDirectory($dir)|Out-Null + $receipt=[ordered]@{ + Schema=1 + Revision='R329' + Status='PASS_PRODUCTION_PINNED_MAIN_PREVENTS_SAME_AND_CROSS_PROCESS_RENAME' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + SetupSHA256=(Get-FileHash -LiteralPath $SetupPath -Algorithm SHA256).Hash + MainSHA256=$expected + Checks=@($checks.ToArray()) + MainExecutableWasLaunched=$false + OriginalOwnerHostTouched=$false + ActualUACInvoked=$false + ProtectedProgramFilesChanged=$false + SecurityFinalAccepted=$false + Scope='Windows NTFS normal rename in disposable user-owned directory; no POSIX semantics or UAC acceptance' + } + $receiptPath=Join-Path $dir 'R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json' + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($receipt|ConvertTo-Json -Depth 5)) + $output=[IO.FileStream]::new($receiptPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$output.Write($bytes,0,$bytes.Length);$output.Flush($true)} + finally{$output.Dispose()} + Write-Output ('R329_TYPED_HOSTED_RECEIPT_SHA256='+(Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash) + } + Write-Output ('R329_NATIVE_MAIN_ANCESTOR_RENAME_GUARD_PASS='+$checks.Count+'/19') +}finally{ + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r329_real_setup_main_',[StringComparison]::Ordinal)){ + $reparse=@(Get-ChildItem -LiteralPath $base -Recurse -Force | + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($reparse.Count -ne 0){throw 'R329_UNEXPECTED_REPARSE_REFUSE_SANDBOX_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-R330-NativeStageDeleteObserver-Native.ps1 b/build/Test-R330-NativeStageDeleteObserver-Native.ps1 new file mode 100644 index 00000000..b7313661 --- /dev/null +++ b/build/Test-R330-NativeStageDeleteObserver-Native.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param() +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop'){throw 'R330_REQUIRES_NATIVE_WINDOWS_POWERSHELL_DOTNET_FRAMEWORK'} +$p=Join-Path $PSScriptRoot 'Test-R302-ActualSetupStageCleanupCrash-Native.ps1' +$s=[IO.File]::ReadAllText($p) +$hits=[regex]::Matches($s,'(?s)\$sourceCode=@''\r?\n(.*?)\r?\n''@') +if($hits.Count -ne 1){throw 'R330_R302_CSHARP_CLASS_BLOCK_MISSING'} +Add-Type -TypeDefinition $hits[0].Groups[1].Value -ErrorAction Stop +$method=[R302RealSetupDeleteWatcher].GetMethod('Bind') +if(!$method){throw 'R330_NATIVE_OBSERVER_NOT_IN_PRODUCTION_TEST_SCRIPT'} +$exact=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +if(!(Test-Path $exact)){throw 'R330_EXACT_WINDOWS_POWERSHELL_NOT_FOUND'} +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r330_native_watcher_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$counts=0 +function Run-Scenario([string]$variant,[int]$index){ + $root=Join-Path $base ($variant+'_'+$index) + $old=Join-Path $root 'old' + $new=Join-Path $root 'new' + [IO.Directory]::CreateDirectory($old)|Out-Null + [IO.Directory]::CreateDirectory($new)|Out-Null + $known=@() + for($i=0;$i -lt 12;$i++){ + foreach($folder in @($old,$new)){ + $file=Join-Path $folder ('owned_{0:D2}.json' -f $i) + [IO.File]::WriteAllText($file,'TEST_ONLY_KNOWN_STAGE_LEAF') + $known+=,$file + } + } + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$exact + $psi.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Seconds 60"' + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $process=$null;$watcher=$null + try { + $process=[Diagnostics.Process]::Start($psi) + if(!$process -or $process.Id -le 0){throw 'R330_SYNTHETIC_CHILD_NOT_STARTED'} + $watcher=[R302RealSetupDeleteWatcher]::new() + $watcher.Arm($old,$new) + if($variant -ceq 'NATIVE_ONLY'){ + $flags=[Reflection.BindingFlags]'Instance,NonPublic' + foreach($member in @('oldWatcher','newWatcher')){ + $f=[R302RealSetupDeleteWatcher].GetField($member,$flags) + if(!$f){throw 'R330_NATIVE_ONLY_NO_MANAGED_WATCHER_REFLECTION'} + $managed=[IO.FileSystemWatcher]$f.GetValue($watcher) + $managed.EnableRaisingEvents=$false + } + } + $watcher.Bind($process) + $noise=Join-Path $old 'noise.tmp' + [IO.File]::WriteAllText($noise,'UNRELATED_CREATED_FILE') + Start-Sleep -Milliseconds 175 + $process.Refresh() + if($process.HasExited -or $watcher.Killed -or $watcher.TerminatedEvent.IsSet){ + throw 'R330_NOISE_TRIPPED_A_KILL_OR_PROCESS_ALREADY_EXITED' + } + [IO.File]::Delete($known[0]) + if(!$watcher.TerminatedEvent.Wait(10000) -or !$watcher.Killed -or + $watcher.TriggerFile -cne [IO.Path]::GetFileName($known[0])){ + throw ('R330_OWNED_STAGE_LEAF_DID_NOT_KILL_EXACT_PROCESS variant='+$variant+ + ' origin='+$watcher.TriggerOrigin+' reason='+$watcher.Failure) + } + $process.Refresh() + if(!$process.HasExited -or $process.ExitCode -eq 0){throw 'R330_KILL_EXIT_NOT_NONZERO'} + if($variant -ceq 'NATIVE_ONLY' -and + ($watcher.TriggerOrigin -cne 'NATIVE_KNOWN_STAGE_LEAF_DELETION' -or + $watcher.NativeKnownDeleteObservations -lt 1)){ + throw 'R330_NATIVE_FALLBACK_NOT_ACTUALLY_TRIGGERED' + } + $script:counts++ + Write-Output ('R330_SYNTHETIC_'+$variant+'_'+$index+'_PASS pid='+$process.Id+ + ' origin='+$watcher.TriggerOrigin+ + ' nativeEvents='+$watcher.NativeKnownDeleteObservations+ + ' managedEvents='+$watcher.Notifications) + }finally{ + if($watcher){$watcher.Dispose()} + if($process){ + try{$process.Refresh();if(!$process.HasExited){$process.Kill();[void]$process.WaitForExit(10000)}} + finally{$process.Dispose()} + } + } +} +try { + foreach($mode in @('NATIVE_ONLY','NATIVE_AND_MANAGED')){ + foreach($i in 1..3){Run-Scenario $mode $i} + } + if($counts -ne 6){throw ('R330_SYNTHETIC_SCENARIO_COUNT_'+$counts)} + Write-Output 'R330_NATIVE_WATCHER_TEST_SIX_SYNTHETIC_PROCESS_KILLS=PASS' +}finally{ + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r330_native_watcher_',[StringComparison]::Ordinal)){ + $links=@(Get-ChildItem $base -Recurse -Force|Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($links.Count -ne 0){throw 'R330_UNEXPECTED_SANDBOX_REPARSE_NO_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 b/build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 new file mode 100644 index 00000000..03178235 --- /dev/null +++ b/build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param( + [string]$SetupPath='', + [switch]$HostedFresh, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R331_REQUIRES_COMPILED_DOTNET_FRAMEWORK_SETUP' +} +$path=(Resolve-Path -LiteralPath $SetupPath).Path +$codePath=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$assembly=[Reflection.Assembly]::LoadFrom($path) +$type=$assembly.GetType('SetupProgram',$true) +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R331_SHIPPED_PE_MISSING_CANONICAL_CSHARP_RESOURCE'} +$reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText($codePath,[Text.Encoding]::UTF8) +$norm={param($t) $t.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm $source)){throw 'R331_EMBEDDED_SOURCE_NOT_EXACT'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +Pass 'COMPILED_SETUP_SOURCE_RESOURCE_EQUALS_REVIEW_SOURCE' + +$method=$type.GetMethod('WriteHostedUpgradeFailure',[Reflection.BindingFlags]'Static,NonPublic') +if($null -ne $method){throw 'R331_SHIPPING_SETUP_HAS_HOSTED_FILE_WRITE_METHOD'} +Pass 'OLD_RUNNER_DIAGNOSTIC_METHOD_ABSENT_FROM_PE' +foreach($token in @( + 'R129_DIAG_','R129_FAIL_CLOSED_DIAGNOSTIC', + 'TBME_UPDATE_DIAGNOSTIC_PHASE', + 'WriteHostedUpgradeFailure')){ + if($source.Contains($token)){throw ('R331_UNTRUSTED_HOST_DIAGNOSTIC_TOKEN_PRESENT_'+$token)} +} +Pass 'NO_ENV_CONTROLLED_HOSTED_DIAGNOSTIC_DATAFLOW' +if($source -notmatch '(?s)if\(Has\(args,"/update"\)\)\s*\{.*?try\s*\{.*?UpgradeExisting\(\).*?\}\s*catch\(Exception ex\)\s*\{\s*if\(!quiet\)MessageBox\.Show\(ex\.Message,.*?return 34;'){ + throw 'R331_UPDATE_FAILURE_CONTRACT_RETURN_34_CHANGED' +} +Pass 'UPDATE_FAILURE_REMAINS_FAIL_CLOSED_EXIT_34' +if($source -notmatch '(?s)static SensorOutcome UpgradeExisting\(\)\s*\{\s*if\(!Directory\.Exists\(AppRoot\)\)throw new InvalidOperationException'){ + throw 'R331_UPDATE_FRESH_ROOT_PRECONDITION_CHANGED' +} +Pass 'UPDATE_FRESH_ROOT_MISSING_RETURNS_BEFORE_JOURNAL_WRITES' +if($source -notmatch 'RecoverUpgradeStageIfAny\(\);' -or + $source -notmatch 'CheckSingleCanonicalRoot\(\);'){ + throw 'R331_UPGRADE_RECOVERY_AND_IDENTITY_GUARDS_REMOVED' +} +Pass 'EXISTING_UPGRADE_RECOVERY_AND_IDENTITY_GUARDS_PRESERVED' +$legacyTest=Join-Path $PSScriptRoot 'Test-Stable170-InPlace-R126.ps1' +$testSource=[IO.File]::ReadAllText((Resolve-Path $legacyTest).Path) +if(!$testSource.Contains('TBME_UPDATE_DIAGNOSTIC_PHASE') -or + $testSource.Contains('R129_DIAG_') -or + $testSource.Contains('R129_FAIL_CLOSED_DIAGNOSTIC')){ + throw 'R331_LEGACY_TEST_REQUIRES_REMOVED_DIAGNOSTIC_OUTPUT' +} +Pass 'LEGACY_UPGRADE_TEST_DOES_NOT_CONSUME_REMOVED_SIDE_EFFECT' +if($checks.Count -ne 7){throw ('R331_NATIVE_LOCAL_CASE_COUNT_'+$checks.Count)} +Write-Output 'R331_COMPILED_SETUP_DIAGNOSTIC_PATH_REMOVAL_LOCAL=7/7_PASS' +if(!$HostedFresh){return} +if($HostLabel -cnotin @('A','B') -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d+$' -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + $env:COMPUTERNAME -match '(?i)EMAD-PC'){ + throw 'R331_REFUSE_LIVE_OWNER_NON_DISPOSABLE_HOST' +} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +if([IO.Directory]::Exists($app) -or (Test-Path $reg) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R331_GUEST_HAS_EXISTING_INSTALLATION_OR_RUNNING_MAIN' +} +Pass 'FRESH_DISPOSABLE_USER_INSTALL_REGISTRY_AND_MAIN_ABSENT' +$base=Join-Path $env:RUNNER_TEMP ('R331_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if([IO.Directory]::Exists($base)){throw 'R331_HOSTED_EVIDENCE_BASE_ALREADY_EXISTS'} +[IO.Directory]::CreateDirectory($base)|Out-Null +$evidence=Join-Path $base 'TBME_R126_STABLE_UPDATE' +[IO.Directory]::CreateDirectory($evidence)|Out-Null +$sentinel=Join-Path $evidence 'external-user-canary.txt' +[IO.File]::WriteAllText($sentinel,'R331_PRESERVE_CANARY',[Text.UTF8Encoding]::new($false)) +$psi=[Diagnostics.ProcessStartInfo]::new() +$psi.FileName=$path +$psi.Arguments='/update /quiet' +$psi.UseShellExecute=$false +$psi.CreateNoWindow=$true +$psi.WorkingDirectory=[IO.Path]::GetDirectoryName($path) +$psi.EnvironmentVariables['GITHUB_ACTIONS']='true' +$psi.EnvironmentVariables['RUNNER_ENVIRONMENT']='github-hosted' +$psi.EnvironmentVariables['RUNNER_OS']='Windows' +$psi.EnvironmentVariables['GITHUB_SHA']=$env:GITHUB_SHA +$psi.EnvironmentVariables['RUNNER_TEMP']=$base +$psi.EnvironmentVariables['TBME_UPDATE_DIAGNOSTIC_PHASE']='NEGATIVE_USER_EDIT' +$proc=[Diagnostics.Process]::Start($psi) +if(!$proc){throw 'R331_REAL_SETUP_PROCESS_NOT_STARTED'} +try{ + if(!$proc.WaitForExit(45000)){ + $proc.Kill();[void]$proc.WaitForExit(10000) + throw 'R331_REAL_SETUP_NEGATIVE_EXIT_TIMEOUT' + } + if($proc.ExitCode -ne 34){throw ('R331_EXPECTED_FRESH_UPDATE_REJECTION_EXIT34_GOT_'+$proc.ExitCode)} +}finally{$proc.Dispose()} +Pass 'REAL_UNMODIFIED_SETUP_BINARY_FRESH_UPDATE_REJECTS_WITH_34' +$bad=Join-Path $evidence 'R129_DIAG_NEGATIVE_USER_EDIT.txt' +if(Test-Path -LiteralPath $bad){throw 'R331_OLD_DIAGNOSTIC_FILE_WAS_WRITTEN'} +if(@([IO.Directory]::GetFileSystemEntries($evidence)).Count -ne 1){throw 'R331_DIAGNOSTIC_CREATED_EXTRA_FILES'} +Pass 'ENV_SPOOFED_DIAGNOSTIC_OUTPUT_WAS_NOT_CREATED' +if([IO.File]::ReadAllText($sentinel) -cne 'R331_PRESERVE_CANARY'){ + throw 'R331_OUTSIDE_CANARY_MODIFIED' +} +Pass 'SENTINEL_PRESERVED' +if([IO.Directory]::Exists($app) -or (Test-Path $reg)){ + throw 'R331_REAL_NEGATIVE_SETUP_CREATED_NEW_APP_OR_REGISTRATION' +} +Pass 'NO_NEW_APPROOT_AND_REGISTRY_ON_FRESH_NEGATIVE' +$receipt=[ordered]@{ + Schema=1 + Revision='R331' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + Status='PASS_FRESH_REAL_SETUP_FAIL_CLOSED_WITHOUT_HOSTED_DIAGNOSTIC_WRITE' + SetupSHA256=(Get-FileHash $path -Algorithm SHA256).Hash + Checks=@($checks.ToArray()) + SetupExit=34 + RealSetupExecuted=$true + DiagnosticOutputAbsent=$true + CanaryPreserved=$true + InstalledAppUntouched=$true + OwnerMachineTouched=$false + MainWasNotLaunched=$true + ElevatedUACNotInvoked=$true + SecurityFinalAccepted=$false +} +$receiptPath=Join-Path $base 'R331_HOSTED_DIAGNOSTIC_REMOVAL_RECEIPT.json' +[IO.File]::WriteAllText($receiptPath,($receipt|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +if($checks.Count -ne 12){throw ('R331_HOSTED_CASE_COUNT_BAD_'+$checks.Count)} +Write-Output ('R331_HOSTED_REAL_SETUP_NEGATIVE_NO_DIAGNOSTIC=12/12 '+$HostLabel+ + ' SHA256='+((Get-FileHash $receiptPath -Algorithm SHA256).Hash)) diff --git a/build/Test-R332-SensorReuseBoundary-Native.ps1 b/build/Test-R332-SensorReuseBoundary-Native.ps1 new file mode 100644 index 00000000..056a5c16 --- /dev/null +++ b/build/Test-R332-SensorReuseBoundary-Native.ps1 @@ -0,0 +1,207 @@ +[CmdletBinding()] +param( + [string]$SetupPath='', + [switch]$HostedGuest, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)) { + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or !(Test-Path -LiteralPath $SetupPath -PathType Leaf)) { + throw 'R332_EXACT_WINDOWS_NETFRAMEWORK_SETUP_REQUIRED' +} +$setupPath=(Resolve-Path -LiteralPath $SetupPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setupPath) +$type=$assembly.GetType('SetupProgram',$true) +$method=$type.GetMethod('ValidatedSensorReuseRoot',[Reflection.BindingFlags]'NonPublic,Static') +$reuse=$type.GetMethod('TryReuseCompatibleSensorLayer',[Reflection.BindingFlags]'NonPublic,Static') +if(!$method -or !$reuse){throw 'R332_REAL_COMPILED_PE_MISSING_BOTH_METHODS'} +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R332_PE_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embeddedSource=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embeddedSource) -cne (& $normalize $source)){ + throw 'R332_COMPILED_SOURCE_NOT_EQUAL_CANONICAL_CODE' +} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +function Invoke-Validated([string]$pf,[string]$local,[string]$profile){ + $a=New-Object 'object[]' 3 + $a[0]=[string]$pf; $a[1]=[string]$local; $a[2]=[string]$profile + return [string]$method.Invoke($null,$a) +} +function Expect-Fail([string]$label,[string]$pf,[string]$local,[string]$profile){ + $refused=$false + try{ + $actual=Invoke-Validated $pf $local $profile + throw ('R332_UNEXPECTED_ACCEPT_'+$label+'='+$actual) + }catch { + $e=$_.Exception + while($null -ne $e.InnerException){$e=$e.InnerException} + $message=$e.Message + if($message -match '^R332_UNEXPECTED_ACCEPT_'){throw $message} + if($message -notmatch '(?i)(root|UNC|profile|canonical|reparse|unavailable|ProgramFiles|escaped|not.*folder)'){ + throw ('R332_WRONG_FAILURE_REASON_'+$label+': '+$message) + } + $refused=$true + } + if(!$refused){throw ('R332_NEGATIVE_NOT_REFUSED_'+$label)} + Pass $label +} +function MoveJunctionCheck([string]$label,[string]$victim,[string]$redirect,[string]$pf,[string]$local,[string]$profile){ + if(![IO.Directory]::Exists($victim) -or + ([IO.File]::GetAttributes($victim) -band [IO.FileAttributes]::ReparsePoint)){ + throw ('R332_NOT_AN_OWNED_TEST_DIRECTORY_'+$label) + } + $retired=$victim+'_retired' + if([IO.Directory]::Exists($retired)){throw ('R332_ALREADY_RETIRED_'+$label)} + [IO.Directory]::Move($victim,$retired) + $isLink=$false + try{ + New-Item -ItemType Junction -Path $victim -Target $redirect -ErrorAction Stop|Out-Null + $isLink=$true + Expect-Fail $label $pf $local $profile + }finally{ + if($isLink -and [IO.Directory]::Exists($victim)){[IO.Directory]::Delete($victim)} + if([IO.Directory]::Exists($retired)){[IO.Directory]::Move($retired,$victim)} + } +} +Pass 'REAL_SETUP_EMBEDDED_SOURCE_EXACT' +$call='string sensorRoot=ValidatedSensorReuseRoot(' +if(([regex]::Matches($source,[regex]::Escape($call))).Count -ne 1 -or + !$source.Contains('Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)') -or + !$source.Contains('CheckRootAncestorsNoReparse(Path.Combine(sensorRoot,"TaskbarMonitorSensorBroker.exe"))') -or + !$source.Contains('CheckRootAncestorsNoReparse(Path.Combine(userRoot,"sensor_supervisor_state.json"))')){ + throw 'R332_REUSE_PRECHECK_NOT_BOUND_BEFORE_FILE_HASHES' +} +Pass 'REUSE_PRECHECK_BOUND_BEFORE_HASH_AND_STATE_READING' +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r332_sensor_reuse_'+[guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$profile=Join-Path $base 'fake profile & literal $test' +$local=Join-Path $profile 'AppData\Local' +$userApp=Join-Path $local 'TaskbarMonitorEnhanced' +$pf=Join-Path $base 'Program Files & literal $test' +$pfProduct=Join-Path $pf 'TaskbarMonitorEnhanced' +$sensor=Join-Path $pfProduct 'SensorBroker' +$broker=Join-Path $sensor 'TaskbarMonitorSensorBroker.exe' +$supervisor=Join-Path $sensor 'TaskbarMonitorSensorSupervisor.exe' +$state=Join-Path $userApp 'sensor_supervisor_state.json' +$external=Join-Path $base 'foreign-canary' +[IO.Directory]::CreateDirectory($userApp)|Out-Null +[IO.Directory]::CreateDirectory($sensor)|Out-Null +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'DO_NOT_MODIFY.txt' +[IO.File]::WriteAllText($canary,'R332_FOREIGN_PRESERVE') +[IO.File]::WriteAllText($broker,'R332_FAKE_BROKER_NOT_RUN') +[IO.File]::WriteAllText($supervisor,'R332_FAKE_SUPERVISOR_NOT_RUN') +[IO.File]::WriteAllText($state,'{"status":"FAKE_NOT_REUSED"}') +try{ + $got=Invoke-Validated $pf $local $profile + if(-not [string]::Equals($got,[IO.Path]::GetFullPath($sensor),[StringComparison]::OrdinalIgnoreCase)){ + throw ('R332_POSITIVE_SYNTHETIC_EXACT_ROOT_MISMATCH_'+$got) + } + Pass 'CANONICAL_FAKE_SAME_USER_PROFILE_AND_PROGRAMFILES_ACCEPTED' + $knownPF=[Environment]::GetFolderPath('ProgramFiles') + $oldPF=$env:ProgramFiles + try{ + $env:ProgramFiles=Join-Path $base 'attacker-supplied-env-ProgramFiles' + if(-not [string]::Equals([Environment]::GetFolderPath('ProgramFiles'),$knownPF, + [StringComparison]::OrdinalIgnoreCase)){ + throw 'R332_ENV_SPOOF_CHANGED_OS_KNOWN_PROGRAMFILES' + } + Pass 'OS_KNOWN_PROGRAMFILES_UNAFFECTED_BY_ENVIRONMENT_SPOOF' + }finally{$env:ProgramFiles=$oldPF} + Expect-Fail 'EMPTY_PROGRAMFILES_REFUSED' '' $local $profile + Expect-Fail 'RELATIVE_PROGRAMFILES_REFUSED' 'Program Files' $local $profile + Expect-Fail 'UNC_PROGRAMFILES_REFUSED' '\\foreign.invalid\programs' $local $profile + Expect-Fail 'DRIVE_ROOT_PROGRAMFILES_REFUSED' ([IO.Path]::GetPathRoot($base)) $local $profile + Expect-Fail 'MISMATCHED_USER_LOCAL_PROFILE_REFUSED' $pf (Join-Path $base 'different\AppData\Local') $profile + Expect-Fail 'UNC_PROFILE_REFUSED' $pf '\\foreign.invalid\user\AppData\Local' '\\foreign.invalid\user' + MoveJunctionCheck 'PROGRAMFILES_ROOT_JUNCTION_REFUSED' $pf $external $pf $local $profile + MoveJunctionCheck 'PROGRAMFILES_PRODUCT_JUNCTION_REFUSED' $pfProduct $external $pf $local $profile + MoveJunctionCheck 'SENSORBROKER_ROOT_JUNCTION_REFUSED' $sensor $external $pf $local $profile + # A directory junction at the exact .exe leaf models an EXE-name reparse point; + # no file symlink privileges or protected ProgramFiles modifications required. + [IO.File]::Delete($broker) + [IO.Directory]::CreateDirectory($external)|Out-Null + $brokerLinked=$false + try{ + New-Item -ItemType Junction -Path $broker -Target $external -ErrorAction Stop|Out-Null + $brokerLinked=$true + Expect-Fail 'BROKER_EXE_LEAF_REPARSE_REFUSED' $pf $local $profile + }finally{ + if($brokerLinked -and [IO.Directory]::Exists($broker)){[IO.Directory]::Delete($broker)} + [IO.File]::WriteAllText($broker,'R332_FAKE_BROKER_NOT_RUN') + } + [IO.File]::Delete($supervisor) + $supervisorLinked=$false + try{ + New-Item -ItemType Junction -Path $supervisor -Target $external -ErrorAction Stop|Out-Null + $supervisorLinked=$true + Expect-Fail 'SUPERVISOR_EXE_LEAF_REPARSE_REFUSED' $pf $local $profile + }finally{ + if($supervisorLinked -and [IO.Directory]::Exists($supervisor)){[IO.Directory]::Delete($supervisor)} + [IO.File]::WriteAllText($supervisor,'R332_FAKE_SUPERVISOR_NOT_RUN') + } + MoveJunctionCheck 'USER_APPROOT_JUNCTION_REFUSED' $userApp $external $pf $local $profile + [IO.File]::Delete($state) + $stateLinked=$false + try{ + New-Item -ItemType Junction -Path $state -Target $external -ErrorAction Stop|Out-Null + $stateLinked=$true + Expect-Fail 'USER_HEALTH_STATE_LEAF_REPARSE_REFUSED' $pf $local $profile + }finally{ + if($stateLinked -and [IO.Directory]::Exists($state)){[IO.Directory]::Delete($state)} + [IO.File]::WriteAllText($state,'{"status":"FAKE_NOT_REUSED"}') + } + if([IO.File]::ReadAllText($canary) -cne 'R332_FOREIGN_PRESERVE' -or + @([IO.Directory]::GetFileSystemEntries($external)).Count -ne 1){ + throw 'R332_FOREIGN_CANARY_OR_EXTERNAL_DIRECTORY_CHANGED' + } + Pass 'EXTERNAL_CANARY_PRESERVED_AFTER_ALL_NEGATIVES' + if($checks.Count -ne 18){throw ('R332_NATIVE_EXPECTED_16_GOT_'+$checks.Count)} + Write-Output ('R332_CANONICAL_SENSOR_REUSE_NET48_PASS='+$checks.Count+'/18') + if(!$HostedGuest){return} + if($HostLabel -cnotin @('A','B') -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R332_HOSTED_GUEST_ENVIRONMENT_NOT_DISPOSABLE' + } + $receiptRoot=Join-Path $env:RUNNER_TEMP ('R332_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if([IO.Directory]::Exists($receiptRoot)){throw 'R332_GUEST_RECEIPT_ALREADY_EXISTS'} + [IO.Directory]::CreateDirectory($receiptRoot)|Out-Null + $evidence=[ordered]@{ + Schema=1 + Revision='R332' + Status='PASS_CANONICAL_SENSOR_REUSE_JUNCTION_FAIL_CLOSED' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + SetupSHA256=(Get-FileHash $setupPath -Algorithm SHA256).Hash + Checks=@($checks.ToArray()) + RealCompiledNetFrameworkSetupLoaded=$true + ProgramFilesRealRootModified=$false + ActualSensorHelperElevated=$false + OwnerInstalledProgramChanged=$false + ExternalCanaryPreserved=$true + AtomicReparseRaceProven=$false + SecurityFinalAccepted=$false + } + $p=Join-Path $receiptRoot 'R332_SENSOR_REUSE_BOUNDARY_RECEIPT.json' + [IO.File]::WriteAllText($p,($evidence|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + Write-Output ('R332_GUEST_TYPED_RECEIPT_SHA256='+(Get-FileHash $p -Algorithm SHA256).Hash) +}finally{ + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r332_sensor_reuse_',[StringComparison]::Ordinal)){ + $links=@(Get-ChildItem $base -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($links.Count -ne 0){throw 'R332_TEST_SANDBOX_HAS_REPARSE_POINTS_REFUSE_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-R333-ShortcutNameOccupied-Native.ps1 b/build/Test-R333-ShortcutNameOccupied-Native.ps1 new file mode 100644 index 00000000..4ac22ce0 --- /dev/null +++ b/build/Test-R333-ShortcutNameOccupied-Native.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param( + [string]$SetupPath='', + [switch]$HostedGuest, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){throw 'R333_COMPILED_DOTNET_FRAMEWORK_SETUP_REQUIRED'} +$setup=(Resolve-Path -LiteralPath $SetupPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setup) +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$occupied=$type.GetMethod('ShortcutNameOccupied',$flags) +$fresh=$type.GetMethod('AssertFreshShortcutNameUnused',$flags) +$foreign=$type.GetMethod('AssertOwnedOrAbsentShortcut',$flags) +if(!$occupied -or !$fresh -or !$foreign){throw 'R333_REAL_SETUP_MISSING_SHORTCUT_PRECHECKS'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +function Occupied([string]$path){ + $args=New-Object 'object[]' 1 + $args[0]=[string]$path + return [bool]$occupied.Invoke($null,$args) +} +function Fresh([string]$path){ + $args=New-Object 'object[]' 1 + $args[0]=[string]$path + [void]$fresh.Invoke($null,$args) +} +function Foreign([string]$path,[string]$target,[string]$arguments){ + $args=New-Object 'object[]' 3 + $args[0]=[string]$path;$args[1]=[string]$target;$args[2]=[string]$arguments + [void]$foreign.Invoke($null,$args) +} +function Expect-Throw([string]$label,[scriptblock]$action,[string]$pattern){ + $message='' + try { + & $action |Out-Null + throw ('R333_FAIL_OPEN_'+$label) + }catch { + $e=$_.Exception + while($null -ne $e.InnerException){$e=$e.InnerException} + $message=$e.Message + } + if($message -match '^R333_FAIL_OPEN_' -or $message -notmatch $pattern){ + throw ('R333_NEGATIVE_NOT_PROVEN_'+$label+' ACTUAL='+$message) + } + Pass $label +} +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R333_EMBEDDED_SOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$norm={param($s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $source) -cne (& $norm $embedded)){throw 'R333_BUILT_SETUP_EMBEDDED_SOURCE_NOT_EXACT'} +Pass 'SETUP_EXACT_EMBEDDED_SOURCE_IDENTITY' +if($occupied.ReturnType -ne [bool]){throw 'R333_PRIVATE_OCCUPIED_RETURN_TYPE_CHANGED'} +Pass 'OCCUPANCY_SIGNATURE_BOOL_PRESERVED' + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r333_occupied_'+[guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$root=Join-Path $base 'start menu & literal $USER' +[IO.Directory]::CreateDirectory($root)|Out-Null +$external=Join-Path $base 'foreign-canary-directory' +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'NEVER_CHANGE.txt' +[IO.File]::WriteAllText($canary,'R333_OUTSIDE_UNTOUCHED') +$link=$null;$retired=$null +try { + $missing=Join-Path $root 'absent.lnk' + if(Occupied $missing){throw 'R333_ABSENT_NAME_OCCUPIED'} + Pass 'EXACT_ABSENT_SHORTCUT_NAME_FALSE' + Fresh $missing + Pass 'FRESH_ABSENT_SHORTCUT_ALLOWED' + + $other=Join-Path $root 'other.lnk' + [IO.File]::WriteAllText($other,'R333_OTHER_LEAF_UNTOUCHED') + if(Occupied $missing){throw 'R333_OTHER_NAME_MATCHED_WRONGLY'} + Pass 'EXACT_LEAF_FILTER_IGNORES_NEIGHBOR' + + $foreignFile=Join-Path $root 'foreign.lnk' + [IO.File]::WriteAllText($foreignFile,'R333_OWNED_BY_OTHER_APP') + $fhash=(Get-FileHash $foreignFile -Algorithm SHA256).Hash + if(!(Occupied $foreignFile)){throw 'R333_FOREIGN_REGULAR_FILE_NOT_OCCUPIED'} + Pass 'REGULAR_FOREIGN_LINK_NAME_OCCUPIED' + if(!(Occupied (Join-Path $root 'FOREIGN.LNK'))){throw 'R333_CASE_INSENSITIVE_LEAF_NOT_MATCHED'} + Pass 'CASE_INSENSITIVE_LITERAL_LEAF_MATCH' + Expect-Throw 'FRESH_REFUSES_FOREIGN_REGULAR_FILE' {Fresh $foreignFile} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + if((Get-FileHash $foreignFile -Algorithm SHA256).Hash -ne $fhash){throw 'R333_FOREIGN_FILE_BYTES_MUTATED'} + Pass 'FOREIGN_FILE_BYTES_PRESERVED' + Expect-Throw 'UNOWNED_SHORTCUT_REFUSES_FOREIGN_FILE' { + Foreign $foreignFile (Join-Path $base 'legitimate-main.exe') '' + } 'FOREIGN_SHORTCUT_NAME_CONFLICT' + + $directoryEntry=Join-Path $root 'subdir.lnk' + [IO.Directory]::CreateDirectory($directoryEntry)|Out-Null + if(!(Occupied $directoryEntry)){throw 'R333_DIRECTORY_WITH_LNK_NAME_NOT_OCCUPIED'} + Pass 'DIRECTORY_WITH_LNK_NAME_OCCUPIED' + Expect-Throw 'FRESH_REFUSES_DIRECTORY_LNK_NAME' {Fresh $directoryEntry} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + + $link=Join-Path $root 'linked.lnk' + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + if(!(Occupied $link)){throw 'R333_JUNCTION_ENTRY_NOT_OCCUPIED'} + Pass 'DIRECTORY_JUNCTION_LNK_OCCUPIES_NAME' + Expect-Throw 'FRESH_REFUSES_JUNCTION_LNK' {Fresh $link} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + $retired=$external+'_temporarily_unavailable' + [IO.Directory]::Move($external,$retired) + if(!(Occupied $link)){throw 'R333_DANGLING_JUNCTION_FALSELY_ABSENT'} + Pass 'DANGLING_REPARSE_LINK_OCCUPIES_NAME' + Expect-Throw 'FRESH_REFUSES_DANGLING_REPARSE' {Fresh $link} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + [IO.Directory]::Move($retired,$external) + $retired=$null + [IO.Directory]::Delete($link) + $link=$null + if([IO.File]::ReadAllText($canary) -cne 'R333_OUTSIDE_UNTOUCHED'){ + throw 'R333_EXTERNAL_CANARY_ALTERED' + } + Pass 'FOREIGN_TARGET_CANARY_PRESERVED' + + if(Occupied (Join-Path $base 'missing-parent\not-here.lnk')){ + throw 'R333_ABSENT_PARENT_FALSELY_OCCUPIED' + } + Pass 'NONEXISTENT_PARENT_RETURNS_ABSENT' + Expect-Throw 'RELATIVE_SHORTCUT_PATH_REFUSED' {Occupied 'relative.lnk'} 'absolute' + Expect-Throw 'NONLNK_EXTENSION_REFUSED' {Occupied (Join-Path $root 'bad.txt')} 'literal .lnk' + Expect-Throw 'WILDCARD_FILENAME_REFUSED' {Occupied (Join-Path $root 'bad*.lnk')} '(?i)literal .lnk|Illegal characters' + + if($source.Contains('try{File.GetAttributes(path);return true;}') -or + !$source.Contains('new DirectoryInfo(parent).GetFileSystemInfos(leaf)')){ + throw 'R333_SHIPPED_OCCUPANCY_STILL_HAS_OLD_SINK' + } + Pass 'SHIPPED_OCCUPANCY_ENUMERATES_EXACT_PARENT_LEAF' + if($checks.Count -ne 22){throw ('R333_NATIVE_CHECK_COUNT_EXPECTED_19_GOT_'+$checks.Count)} + Write-Output ('R333_NATIVE_SHORTCUT_EXACT_LEAF_OCCUPANCY_PASS='+$checks.Count+'/22') + + if(!$HostedGuest){return} + if($HostLabel -cnotin @('A','B') -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R333_GUEST_MUST_BE_DISPOSABLE_GITHUB_WINDOWS' + } + $receiptDir=Join-Path $env:RUNNER_TEMP ('R333_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if([IO.Directory]::Exists($receiptDir)){throw 'R333_HOSTED_GUEST_PROOF_EXISTS'} + [IO.Directory]::CreateDirectory($receiptDir)|Out-Null + $proof=[ordered]@{ + Schema=1 + Revision='R333' + Status='PASS_EXACT_SHORTCUT_OCCUPANCY_INCLUDING_DANGLING_REPARSE' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash + Checks=@($checks.ToArray()) + RealCompiledSetupLoaded=$true + RealMainLaunched=$false + ForeignCanaryPreserved=$true + ActualUacInvoked=$false + OriginalOwnerHostTouched=$false + AtomicPathRaceProven=$false + SecurityFinalAccepted=$false + } + $receipt=Join-Path $receiptDir 'R333_SHORTCUT_OCCUPANCY_RECEIPT.json' + [IO.File]::WriteAllText($receipt,($proof|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + Write-Output ('R333_HOSTED_GUEST_RECEIPT_SHA256='+(Get-FileHash $receipt -Algorithm SHA256).Hash) +}finally{ + if($link -and [IO.Directory]::Exists($link)){ + [IO.Directory]::Delete($link) + } + if($retired -and [IO.Directory]::Exists($retired)){ + [IO.Directory]::Move($retired,$external) + } + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r333_occupied_',[StringComparison]::Ordinal)){ + $reparse=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue| + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($reparse.Count){throw 'R333_UNEXPECTED_REPARSE_REFUSE_TEST_SANDBOX_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-R334-CommandSinkContracts-Native.ps1 b/build/Test-R334-CommandSinkContracts-Native.ps1 new file mode 100644 index 00000000..1882b833 --- /dev/null +++ b/build/Test-R334-CommandSinkContracts-Native.ps1 @@ -0,0 +1,236 @@ +[CmdletBinding()] +param([string]$SetupPath='', [switch]$HostedGuest, [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if(!$SetupPath){$SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe'} +if($PSVersionTable.PSEdition -cne 'Desktop' -or !(Test-Path $SetupPath -PathType Leaf)){ + throw 'R334_REQUIRES_COMPILED_WINDOWS_DOTNET_FRAMEWORK_SETUP' +} +$setup=(Resolve-Path -LiteralPath $SetupPath).Path +$a=[Reflection.Assembly]::LoadFrom($setup) +$t=$a.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$helper=$t.GetMethod('BuildElevatedSensorProcessInfo',$flags) +$pin=$t.GetMethod('OpenPinnedVerifiedMainForLaunch',$flags) +$mainInfo=$t.GetMethod('BuildPinnedMainProcessInfo',$flags) +$cleanup=$t.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) +$appProp=$t.GetProperty('AppRoot',$flags) +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Invoke-Fn([Reflection.MethodInfo]$method,[object[]]$inputs){ + $native=New-Object 'object[]' $inputs.Count + for($i=0;$i -lt $inputs.Count;$i++){ + $native[$i]=if($null -eq $inputs[$i]){$null}else{$inputs[$i].PSObject.BaseObject} + } + try {return $method.Invoke($null,$native)} + catch{ + $e=$_.Exception + while($e.InnerException){$e=$e.InnerException} + throw $e + } +} +function Reject([string]$label,[scriptblock]$action,[string]$match){ + $why='' + try{& $action|Out-Null;throw ('R334_FAIL_OPEN_'+$label)} + catch{ + $errorObject=$_.Exception + while($errorObject.InnerException){$errorObject=$errorObject.InnerException} + $why=$errorObject.Message + } + if($why -match '^R334_FAIL_OPEN_' -or $why -notmatch $match){ + throw ('R334_WRONG_REJECTION_'+$label+'='+$why) + } + Pass $label +} +function Decode([string]$arguments,[switch]$RequireEncoded){ + $head='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if($arguments.Contains(' -File ')){throw 'R334_UNTRUSTED_FILE_SCRIPT_TRANSPORT'} + if($arguments.StartsWith($head,[StringComparison]::Ordinal)){ + $blob=$arguments.Substring($head.Length) + if($blob -cnotmatch '^[A-Za-z0-9+/]+={0,2}$'){throw 'R334_UNSAFE_ENCODED_COMMAND_ALPHABET'} + return [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($blob)) + } + if($RequireEncoded){throw 'R334_CLEANUP_REQUIRES_ENCODED_COMMAND_NOT_COMMAND_SOURCE'} + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command "& { ' + $suffix=' }"' + if(!$arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or + !$arguments.EndsWith($suffix,[StringComparison]::Ordinal) -or + $arguments.Length -gt 30000){ + throw 'R334_UNEXPECTED_TRUSTED_INLINE_HELPER_BOOTSTRAP' + } + $source=$arguments.Substring($prefix.Length,$arguments.Length-$prefix.Length-$suffix.Length) + if($source.Contains('"') -or $source.Contains([string][char]0)){ + throw 'R334_INLINE_HELPER_BOOTSTRAP_QUOTE_OR_NULL_IN_SOURCE' + } + return $source +} +if(!$helper -or !$pin -or !$mainInfo -or !$cleanup -or !$appProp){throw 'R334_MISSING_SHIPPED_PROCESS_FACTORIES'} +Pass 'EXACT_THREE_PROCESS_STARTINFO_FACTORIES_PRESENT' +$resource=$a.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R334_CANONICAL_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$norm={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm $source)){throw 'R334_SHIPPED_PE_NOT_MATCHING_CURRENT_SOURCE'} +Pass 'REAL_PE_EMBEDDED_SOURCE_IDENTICAL' +$system=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$payload='C:\NOT_REAL & ;\payload' +$app='C:\FAKE & ;\AppData\Local\TaskbarMonitorEnhanced' +$user="R334';Write-Output MARKER_NEVER_EXECUTED;#" +$args=New-Object 'object[]' 4 +$args[0]='Install';$args[1]=$payload;$args[2]=$app;$args[3]=$user +$psi=[Diagnostics.ProcessStartInfo](Invoke-Fn $helper $args) +if(!$psi.FileName.Equals($system,[StringComparison]::OrdinalIgnoreCase) -or + !$psi.WorkingDirectory.Equals([IO.Path]::GetDirectoryName($system),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R334_ELEVATED_POWERSHELL_HOST_NOT_SYSTEM32' +} +Pass 'HELPER_PINNED_SYSTEM32_EXECUTABLE_AND_WORKDIR' +if($psi.UseShellExecute -ne $true -or $psi.Verb -cne 'runas'){throw 'R334_NATIVE_UAC_VERB_MISSING'} +Pass 'HELPER_REQUIRES_NATIVE_RUNAS_WITHOUT_BYPASS' +$text=Decode $psi.Arguments +Pass 'HELPER_COMMAND_IS_BOUNDED_UTF16_ENCODED_OR_TRUSTED_BASE64_INLINE' +foreach($pair in @(@('PAYLOAD',$payload),@('APPROOT',$app),@('IDENTITY',$user))){ + $blob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$pair[1])) + if(!$text.Contains("'"+$blob+"'") -or + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($blob)) -cne $pair[1]){ + throw ('R334_HELPER_ARGUMENT_BYTE_ROUNDTRIP_FAILED_'+$pair[0]) + } + Pass ('HELPER_UTF8_BASE64_DATA_ONLY_'+$pair[0]) +} +if($text.Contains('MARKER_NEVER_EXECUTED') -or + $psi.Arguments.Contains('MARKER_NEVER_EXECUTED') -or + $text.Contains($user) -or $text.Contains($app) -or $text.Contains($payload)){ + throw 'R334_UNTRUSTED_SYNTAX_ESCAPED_FROM_BASE64_STRING' +} +Pass 'HOSTILE_POWERSHELL_SYNTAX_NOT_INSERTED_IN_SCRIPT_SOURCE' +if(!$text.Contains("-Mode 'Install'") -or + !$text.Contains('-ExpectedBrokerSHA256') -or + !$text.Contains('-ExpectedSupervisorSHA256') -or + !$text.Contains('-ExpectedBackendZipSHA256')){throw 'R334_STATIC_HELPER_MODE_OR_HASH_BINDING_REMOVED'} +Pass 'HELPER_EXACT_MODE_LITERAL_AND_THREE_HASH_PINS' +Reject 'HELPER_REFUSES_NON_ALLOWLISTED_MODE' { + $values=New-Object 'object[]' 4;$values[0]='Inject';$values[1]=$payload;$values[2]=$app;$values[3]=$user + [void](Invoke-Fn $helper $values) +} 'Unexpected elevated sensor mode' +Reject 'HELPER_REFUSES_OVERLONG_ARGUMENT' { + $values=New-Object 'object[]' 4;$values[0]='Install';$values[1]=$payload;$values[2]=$app;$values[3]=('x'*4097) + [void](Invoke-Fn $helper $values) +} 'missing or too long' +$fallback=$false +foreach($n in @(2500,3500,4050)){ + $values=New-Object 'object[]' 4;$values[0]='Uninstall';$values[1]=$payload;$values[2]=$app;$values[3]=('R334;'+('x'*$n)) + $result=[Diagnostics.ProcessStartInfo](Invoke-Fn $helper $values) + if($result.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -Command "',[StringComparison]::Ordinal)){ + $fallback=$true + if($result.Arguments.Contains('R334;') -or $result.Arguments.Length -gt 30000 -or + $result.Verb -cne 'runas' -or $result.UseShellExecute -ne $true){ + throw 'R334_LONG_HELPER_COMMAND_EXPOSED_RAW_UNTRUSTED_SOURCE' + } + Pass 'LONG_HELPER_COMMAND_FALLBACK_BOUNDED_WITH_ONLY_BASE64_USER_DATA' + break + } +} +if(!$fallback){throw 'R334_LONG_HELPER_CANNOT_REACH_TRUSTED_INLINE_FALLBACK'} + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r334_command_'+[guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$main=Join-Path $base 'TaskbarMonitorEnhanced.exe' +$fh=$null +try{ + $res=$a.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') + if(!$res){throw 'R334_EMBEDDED_MAIN_PE_NOT_AVAILABLE'} + $mem=[IO.MemoryStream]::new() + try{$res.CopyTo($mem)}finally{$res.Dispose()} + $bytes=$mem.ToArray();$mem.Dispose() + [IO.File]::WriteAllBytes($main,$bytes) + $hash=[Security.Cryptography.SHA256]::Create() + try{$expected=([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-','')} + finally{$hash.Dispose()} + $values=New-Object 'object[]' 3;$values[0]=$main;$values[1]=$base;$values[2]=$expected + $fh=[IO.FileStream](Invoke-Fn $pin $values) + Pass 'MAIN_EXACT_EMBEDDED_SHA_FILESTREAM_READ_PIN' + $values=New-Object 'object[]' 2;$values[0]=$fh;$values[1]=$base + $mainPsi=[Diagnostics.ProcessStartInfo](Invoke-Fn $mainInfo $values) + if(!$mainPsi.FileName.Equals([IO.Path]::GetFullPath($main),[StringComparison]::OrdinalIgnoreCase) -or + $mainPsi.Arguments -cne '' -or $mainPsi.UseShellExecute -ne $false -or + $mainPsi.ErrorDialog -ne $false){throw 'R334_SHA_PINNED_MAIN_HAS_COMMAND_OR_SHELL'} + Pass 'MAIN_SHA_PINNED_EXACT_FILENAME_NO_ARGUMENTS_NO_SHELL' + Reject 'MAIN_REFUSES_DIFFERENT_INSTALL_ROOT' { + $v=New-Object 'object[]' 2;$v[0]=$fh;$v[1]=(Join-Path $base 'wrong') + [void](Invoke-Fn $mainInfo $v) + } 'escaped|unavailable' + $fh.Position=0 + $h=[Security.Cryptography.SHA256]::Create() + try{$actual=([BitConverter]::ToString($h.ComputeHash($fh))).Replace('-','')} + finally{$h.Dispose()} + if($actual -cne $expected){throw 'R334_MAIN_FILE_BYTES_CHANGED_WHILE_PINNED'} + Pass 'MAIN_HANDLE_SHA_MATCHES_IMMUTABLE_SETUP_RESOURCE' +}finally{ + if($fh){$fh.Dispose()} + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r334_command_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($base,$true) + } +} +$root=[string]$appProp.GetValue($null,$null) +$pidValue=[Diagnostics.Process]::GetCurrentProcess().Id +$ticks=[DateTime]::UtcNow.AddMinutes(-1).Ticks +$v=New-Object 'object[]' 3;$v[0]=$root;$v[1]=$pidValue;$v[2]=$ticks +$cleanPsi=[Diagnostics.ProcessStartInfo](Invoke-Fn $cleanup $v) +if(!$cleanPsi.FileName.Equals($system,[StringComparison]::OrdinalIgnoreCase) -or + $cleanPsi.UseShellExecute -ne $false -or $cleanPsi.CreateNoWindow -ne $true -or + !$cleanPsi.WorkingDirectory.Equals([Environment]::SystemDirectory,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R334_CLEANUP_NOT_EXACT_NATIVE_UNELEVATED_POWERSHELL' +} +Pass 'CLEANUP_EXACT_SYSTEM_POWERSHELL_WITHOUT_SHELL' +$decoded=Decode $cleanPsi.Arguments -RequireEncoded +$rootBlob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($root)) +if(!$decoded.Contains("'"+$rootBlob+"'") -or $decoded.Contains($root) -or + $decoded.Contains('__ROOT_B64__')){throw 'R334_CLEANUP_USER_ROOT_ENTERED_COMMAND_AS_RAW_PATH'} +Pass 'CLEANUP_ROOT_TRANSPORTED_ONLY_AS_UTF8_BASE64_DATA' +if(!$decoded.Contains('TBME_CLEANUP_WRONG_EFFECTIVE_TOKEN') -or + !$decoded.Contains('TBME_CLEANUP_ROOT_NOT_CANONICAL') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('$lease.DeletePinned($dir)') -or + !$decoded.Contains('SetFileInformationByHandle') -or + $decoded.Contains('$lease.Release($dir)') -or + $decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R334_CLEANUP_OWNER_SID_REPARSE_NONRECURSIVE_GUARDS_MISSING' +} +Pass 'CLEANUP_TOKEN_CANONICAL_ROOT_AND_NONRECURSIVE_GUARDS' +foreach($case in @( + @('FOREIGN_ROOT',(Join-Path $root 'wrong'),$pidValue,$ticks), + @('ZERO_PID',$root,0,$ticks), + @('ZERO_START',$root,$pidValue,0))){ + $item=$case + Reject ('CLEANUP_REJECTS_'+$item[0]) { + $v=New-Object 'object[]' 3;$v[0]=[string]$item[1];$v[1]=[int]$item[2];$v[2]=[long]$item[3] + [void](Invoke-Fn $cleanup $v) + } 'exact owner root and parent identity' +} +Pass 'ALL_THREE_FACTORIES_INSPECTED_WITHOUT_LAUNCHING_ANY_PROCESS' +if($checks.Count -ne 24){throw ('R334_WRONG_TOTAL_'+$checks.Count)} +Write-Output ('R334_THREE_COMMAND_SINKS_NATIVE='+$checks.Count+'/24 PASS') + +if(!$HostedGuest){return} +if($HostLabel -cnotin @('A','B') -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R334_REQUIRE_DISPOSABLE_HOSTED_WINDOWS_GUEST' +} +$folder=Join-Path $env:RUNNER_TEMP ('R334_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if([IO.Directory]::Exists($folder)){throw 'R334_EXISTING_GUEST_WITNESS_REFUSED'} +[IO.Directory]::CreateDirectory($folder)|Out-Null +$receipt=[ordered]@{ + Schema=1;Revision='R334';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA; + Status='PASS_THREE_PROCESS_COMMAND_SINK_TRUST_CONTRACTS'; + SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash; + Checks=@($checks.ToArray());CompiledSetupReflected=$true; + HelperAndCleanupInputsOnlyInEncodedData=$true;ShaPinnedMainNoShell=$true; + NativeUacWasNotInvoked=$true;NoCleanupOrMainProcessLaunched=$true; + WholeSecurityAccepted=$false +} +$p=Join-Path $folder 'R334_COMMAND_SINK_TRUST_RECEIPT.json' +[IO.File]::WriteAllText($p,($receipt|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +Write-Output ('R334_HOSTED_GUEST_RECEIPT_SHA256='+(Get-FileHash $p -Algorithm SHA256).Hash) diff --git a/build/Test-R335-AtomicUserLogSink-Native.ps1 b/build/Test-R335-AtomicUserLogSink-Native.ps1 new file mode 100644 index 00000000..57779899 --- /dev/null +++ b/build/Test-R335-AtomicUserLogSink-Native.ps1 @@ -0,0 +1,241 @@ +[CmdletBinding()] +param([string]$SetupPath='', [switch]$HostedGuest,[ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop'){throw 'R335_REQUIRES_NATIVE_WINDOWS_NETFRAMEWORK'} +if([string]::IsNullOrWhiteSpace($SetupPath)) { + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if(!(Test-Path -LiteralPath $SetupPath -PathType Leaf)){throw 'R335_EXACT_COMPILED_SETUP_NOT_PRESENT'} +$setup=(Resolve-Path -LiteralPath $SetupPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setup) +$resource=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R335_PRODUCTION_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helperPath=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$production=[IO.File]::ReadAllText($helperPath,[Text.Encoding]::UTF8) +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $production) -cne (& $normalize $embedded)){throw 'R335_COMPILED_PE_HELPER_SOURCE_NOT_EXACT'} +$cls=[regex]::Matches($production,'(?s)Add-Type -TypeDefinition @''\r?\n(.*?)\r?\n''@') +if($cls.Count -ne 1 -or !$cls[0].Groups[1].Value.Contains('public sealed class TBMEScopedUserLogLease : IDisposable')){throw 'R335_REAL_PRODUCTION_HANDLE_CLASS_NOT_UNIQUE'} +Add-Type -TypeDefinition $cls[0].Groups[1].Value -ErrorAction Stop +if($null -eq ('TBMEScopedUserLogLease' -as [type])){throw 'R335_PRODUCTION_HANDLE_CLASS_NOT_LOADED'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function CanRename([string]$path){ + $to=$path+'_moved' + try{ + [IO.Directory]::Move($path,$to) + [IO.Directory]::Move($to,$path) + return $true + }catch [IO.IOException]{return $false} + catch [UnauthorizedAccessException]{return $false} +} +function Attempt-FileRename([string]$file){ + $out=$file+'_renamed' + try{[IO.File]::Move($file,$out);[IO.File]::Move($out,$file);return $true} + catch [IO.IOException]{return $false} + catch [UnauthorizedAccessException]{return $false} +} +function Refuse([string]$name,[scriptblock]$action,[string]$reason){ + $message='' + try{& $action|Out-Null;throw 'R335_FAIL_OPEN'} + catch{ + $ex=$_.Exception + while($ex.InnerException){$ex=$ex.InnerException} + $message=$ex.Message + } + if($message -eq 'R335_FAIL_OPEN' -or $message -notmatch $reason){ + throw ('R335_REFUSAL_WRONG_'+$name+' ERROR='+$message) + } + Pass $name +} +Pass 'REAL_COMPILED_SETUP_EMBEDS_EXACT_PRODUCTION_HANDLE_SOURCE' +Pass 'PRODUCTION_LOG_GUARD_CLASS_LOADED_FROM_EMBEDDED_HELPER' +# R335 regression of the full trusted PE-helper transport, not just its C# +# handle type. It must preserve the literal here-string while compacting all +# other safe lines and stay within the Windows ShellExecute command limit. +$builder=$assembly.GetType('SetupProgram',$true).GetMethod( + 'BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') +if(!$builder){throw 'R335_EXACT_ELEVATED_PROCESS_BUILDER_NOT_PRESENT'} +$args=New-Object 'object[]' 4 +$args[0]='Install' +$args[1]='C:\R335_TEST_ONLY\payload & literal' +$args[2]='C:\R335_TEST_ONLY\AppData\Local\TaskbarMonitorEnhanced' +$args[3]='R335\USER_TEST_ONLY' +$psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) +$system=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +if(!$psi.FileName.Equals($system,[StringComparison]::OrdinalIgnoreCase) -or + $psi.Verb -cne 'runas' -or $psi.UseShellExecute -ne $true){ + throw 'R335_HELPER_PROCESS_UAC_AND_SYSTEM_EXE_NO_LONGER_PINNED' +} +Pass 'BUILD_ELEVATED_HELPER_PRESERVES_SYSTEM32_RUNAS_NO_LAUNCH' +$argline=$psi.Arguments +$inline='-NoProfile -NonInteractive -WindowStyle Hidden -Command "& { ' +$tail=' }"' +$encoded='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' +if($argline.StartsWith($encoded,[StringComparison]::Ordinal)){ + $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($argline.Substring($encoded.Length))) +}elseif($argline.StartsWith($inline,[StringComparison]::Ordinal) -and + $argline.EndsWith($tail,[StringComparison]::Ordinal) -and + $argline.Length -le 30000){ + $bootstrap=$argline.Substring($inline.Length,$argline.Length-$inline.Length-$tail.Length) +}else{throw 'R335_TRANSPORT_NOT_BOUNDED_INLINE_OR_UTF16_ENCODED'} +if($bootstrap.Contains('R335_TEST_ONLY') -or $argline.Contains('R335_TEST_ONLY')){ + throw 'R335_UNTRUSTED_PATH_REACHED_COMMAND_TEXT_OUTSIDE_BASE64' +} +Pass 'TRUSTED_HELPER_BOOTSTRAP_BOUNDED_AND_UNTRUSTED_PATHS_ENCODED' +$parts=[regex]::Matches($bootstrap,'\$blob=\[Convert\]::FromBase64String\(''([A-Za-z0-9+/=]+)''\)') +if($parts.Count -ne 1){throw 'R335_TRANSPORT_COMPRESSED_PE_HELPER_SOURCE_MISSING'} +$packed=[Convert]::FromBase64String($parts[0].Groups[1].Value) +$stream=[IO.MemoryStream]::new($packed) +$gzip=[IO.Compression.GZipStream]::new($stream,[IO.Compression.CompressionMode]::Decompress) +$textReader=[IO.StreamReader]::new($gzip,[Text.Encoding]::UTF8) +try{$trusted=$textReader.ReadToEnd()} +finally{$textReader.Dispose();$gzip.Dispose();$stream.Dispose()} +$tok=$null;$parseErr=$null +[Management.Automation.Language.Parser]::ParseInput($trusted,[ref]$tok,[ref]$parseErr)|Out-Null +if(@($parseErr).Count -ne 0){throw ('R335_TRANSPORTED_PE_HELPER_POWERSHELL_PARSE_ERRORS_'+@($parseErr).Count)} +Pass 'COMPRESSED_TRUSTED_HELPER_FULL_SCRIPT_PS51_PARSE_PASS' +$literal=$cls[0].Value.Replace(([string][char]13+[string][char]10),[string][char]10) +if(!$trusted.Contains($literal) -or + !$trusted.Contains('public sealed class TBMEScopedUserLogLease : IDisposable')){ + throw 'R335_TRUSTED_CSHARP_HERE_LITERAL_WAS_COMPACTED_OR_CORRUPTED' +} +Pass 'TRUSTED_NATIVE_CSHARP_HERE_LITERAL_PRESERVED_BYTE_IDENTICAL' +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r335_loglease_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$profile=Join-Path $base 'profile & literal dollar' +$local=Join-Path $profile 'AppData\Local' +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$logs=Join-Path $app 'Logs' +$file=Join-Path $logs 'sensor_install.log' +$external=Join-Path $base 'foreign-outside' +[IO.Directory]::CreateDirectory($logs)|Out-Null +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'NO_WRITE.txt' +[IO.File]::WriteAllText($canary,'R335_FOREIGN_CANARY') +[IO.File]::WriteAllText($file,'PREEXISTING_LOG') +$lease=$null +try{ + if(!(CanRename $logs) -or !(CanRename $app) -or !(Attempt-FileRename $file)){ + throw 'R335_SANDBOX_BASELINE_RENAME_EXPECTED_PERMITTED' + } + Pass 'BASELINE_UNGUARDED_DIR_AND_LEAF_RENAME_ALLOWED' + $lease=[TBMEScopedUserLogLease]::new($profile,$app,$logs,$file) + Pass 'OPENED_ALL_FIVE_NO_REPARSE_COMPONENT_HANDLES_AND_EXACT_FILE' + if(CanRename $logs){throw 'R335_LOGS_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_LOGS_FOLDER_RENAME' + if(CanRename $app){throw 'R335_APPROOT_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_APPROOT_FOLDER_RENAME' + if(CanRename $local){throw 'R335_LOCAL_PARENT_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_LOCAL_PARENT_RENAME' + if(Attempt-FileRename $file){throw 'R335_LEAF_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_EXACT_LOG_FILE_RENAME' + $lease.Append('R335_APPEND_IS_CANARY_ONLY') + # The FileStream holding WRITE access intentionally denies external reads + # when their share mode denies writes. Inspect the bytes after lease disposal. + $win=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $child='param([string]$p);try{[IO.Directory]::Move($p,($p+''_actor''));[IO.Directory]::Move(($p+''_actor''),$p);exit 61}catch [IO.IOException]{exit 0}catch [UnauthorizedAccessException]{exit 0}' + $actor=Join-Path $base 'sandbox-actor.ps1' + [IO.File]::WriteAllText($actor,$child,[Text.UTF8Encoding]::new($false)) + & $win -NoProfile -NonInteractive -File $actor -p $logs + if($LASTEXITCODE -ne 0){throw ('R335_SEPARATE_PROCESS_RENAME_NOT_DENIED_'+$LASTEXITCODE)} + Pass 'SEPARATE_UNELEVATED_PROCESS_RENAME_BLOCKED_DURING_GUARD' + $lease.Dispose();$lease=$null + if(![IO.File]::ReadAllText($file).Contains('R335_APPEND_IS_CANARY_ONLY')){ + throw 'R335_GUARDED_LOG_WRITE_NOT_PERSISTED' + } + Pass 'EXACT_PINNED_FILESTREAM_WRITE_SUCCEEDED' + if(!(CanRename $logs) -or !(CanRename $app) -or !(Attempt-FileRename $file)){ + throw 'R335_FOLDER_OR_FILE_RENAME_STILL_BLOCKED_AFTER_DISPOSAL' + } + Pass 'RENAMES_REENABLED_AFTER_ALL_HANDLES_CLOSED' + $retired=$logs+'_clean' + [IO.Directory]::Move($logs,$retired) + $link=$false + try{ + New-Item -ItemType Junction -Path $logs -Target $external -ErrorAction Stop|Out-Null + $link=$true + Refuse 'LOGS_JUNCTION_REJECTED_BEFORE_OPEN_FILE' {[TBMEScopedUserLogLease]::new($profile,$app,$logs,$file)} 'SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED' + }finally{ + if($link -and [IO.Directory]::Exists($logs)){[IO.Directory]::Delete($logs)} + [IO.Directory]::Move($retired,$logs) + } + [IO.File]::Delete($file) + $link=$false + try{ + New-Item -ItemType Junction -Path $file -Target $external -ErrorAction Stop|Out-Null + $link=$true + Refuse 'LOG_FILE_REPARSE_LINK_REJECTED_BEFORE_WRITE' {[TBMEScopedUserLogLease]::new($profile,$app,$logs,$file)} 'SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED|SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_5' + }finally{ + if($link -and [IO.Directory]::Exists($file)){[IO.Directory]::Delete($file)} + [IO.File]::WriteAllText($file,'RESTORED_LOG') + } + Refuse 'FOREIGN_APP_ROOT_LEXICALLY_REFUSED' {[TBMEScopedUserLogLease]::new($profile,$external,$logs,$file)} 'SENSOR_LOG_HANDLE_CANONICAL_LOG_PATH_MISMATCH' + if([IO.File]::ReadAllText($canary) -cne 'R335_FOREIGN_CANARY'){ + throw 'R335_OUTSIDE_CANARY_MODIFIED' + } + Pass 'OUTSIDE_FOREIGN_CANARY_UNMODIFIED' + $resultFile=Join-Path $logs 'sensor_install_result.json' + $resultLease=[TBMEScopedUserLogLease]::new($profile,$app,$logs,$resultFile) + try{$resultLease.Overwrite('{"Status":"R335_OK","SensorHealthy":false}'+[Environment]::NewLine)} + finally{$resultLease.Dispose()} + $result=Get-Content -LiteralPath $resultFile -Raw|ConvertFrom-Json + if($result.Status -cne 'R335_OK' -or $result.SensorHealthy -ne $false){ + throw 'R335_UTF8_BOM_RESULT_JSON_NOT_VALID' + } + Pass 'GUARDED_RESULT_JSON_UTF8_BOM_AND_SCHEMA_VALID' + $resultLease=[TBMEScopedUserLogLease]::new($profile,$app,$logs,$resultFile) + try{$resultLease.Overwrite('{"Status":"R335_FINAL"}'+[Environment]::NewLine)} + finally{$resultLease.Dispose()} + $bytes=[IO.File]::ReadAllBytes($resultFile) + $text=[Text.Encoding]::UTF8.GetString($bytes) + if($text.Contains('R335_OK') -or !$text.Contains('R335_FINAL') -or + $bytes[0] -ne 239 -or $bytes[1] -ne 187 -or $bytes[2] -ne 191){ + throw 'R335_BOM_RESULT_OVERWRITE_LEAKED_PREVIOUS_JSON' + } + Pass 'GUARDED_RESULT_OVERWRITE_TRUNCATES_AND_RETAINS_UTF8_BOM' + [IO.File]::Delete($resultFile) + $linked=$false + try{ + New-Item -ItemType Junction -Path $resultFile -Target $external -ErrorAction Stop|Out-Null + $linked=$true + Refuse 'RESULT_JSON_REPARSE_LEAF_REFUSED' {[TBMEScopedUserLogLease]::new($profile,$app,$logs,$resultFile)} 'SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED|SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_5' + }finally{ + if($linked -and [IO.Directory]::Exists($resultFile)){[IO.Directory]::Delete($resultFile)} + } + if([IO.File]::ReadAllText($canary) -cne 'R335_FOREIGN_CANARY'){ + throw 'R335_RESULT_REPARSE_WROTE_OUTSIDE_CANARY' + } + Pass 'RESULT_JSON_REPARSE_EXTERNAL_CANARY_INTACT' + if($checks.Count -ne 23){throw ('R335_EXPECTED_19_CHECKS_ACTUAL_'+$checks.Count)} + Write-Output ('R335_DISPOSABLE_USER_LOG_HANDLE_PROBE=23/23_PASS') + if($HostedGuest){ + if($HostLabel -cnotin @('A','B') -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R335_TEST_REQUIRES_DISPOSABLE_GITHUB_WINDOWS' + } + $dir=Join-Path $env:RUNNER_TEMP ('R335_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path $dir){throw 'R335_GUEST_RAW_RECEIPT_EXISTS'} + [IO.Directory]::CreateDirectory($dir)|Out-Null + $j=[ordered]@{Schema=1;Revision='R335';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA; + Status='PASS_SCOPED_NONDELETE_HANDLE_LOGS_APPROOT_AND_FILE'; + Checks=@($checks.ToArray());RealElevatedHelperLaunched=$false; + ActualProgramFilesModified=$false;AtomicCrossUserUacProven=$false; + ForeignCanaryPreserved=$true;SecurityFinalAccepted=$false} + $receipt=Join-Path $dir 'R335_ATOMIC_LOG_HANDLE_PROBE.json' + [IO.File]::WriteAllText($receipt,($j|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + Write-Output ('R335_HOSTED_RAW_RECEIPT_SHA256='+(Get-FileHash $receipt -Algorithm SHA256).Hash) + } +}finally{ + if($lease){$lease.Dispose()} + if([IO.Directory]::Exists($base) -and [IO.Path]::GetFileName($base).StartsWith('tbme_r335_loglease_',[StringComparison]::Ordinal)){ + $links=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue| + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($links.Count -ne 0){throw 'R335_SANDBOX_REPARSE_REFUSE_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-R337-FirstInstallColdOrphan-Native.ps1 b/build/Test-R337-FirstInstallColdOrphan-Native.ps1 new file mode 100644 index 00000000..64b31c59 --- /dev/null +++ b/build/Test-R337-FirstInstallColdOrphan-Native.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('baseline','candidate')][string]$Variant, + [Parameter(Mandatory=$true)][string]$ProductRoot, + [Parameter(Mandatory=$true)][string]$SetupPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R337_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +$root=Join-Path $env:RUNNER_TEMP ('R337_'+$Variant+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if(Test-Path -LiteralPath $root){throw 'R337_COLLIDING_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($root) +$receipt=Join-Path $root 'R337_FIRST_INSTALL_COLD_RECOVERY.json' +$checks=[Collections.Generic.List[string]]::new() +$failure='' +$remaining=$null +$outside=Join-Path $root 'OUTSIDE_CANARY.txt' +$canary='R337_OUTSIDE_CANARY_NOT_MODIFIED' +[IO.File]::WriteAllText($outside,$canary) +$source=(Resolve-Path (Join-Path $ProductRoot 'installer\TBME_Setup_Elevated_Helper.ps1')).Path +$process=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +try{ + if(-not [IO.File]::Exists($SetupPath) -or + -not [string]::Equals([IO.Path]::GetFullPath($process), + ([IO.Path]::GetFullPath((Get-Process -Id $PID).Path)), + [StringComparison]::OrdinalIgnoreCase)){ + throw 'R337_EXACT_SYSTEM_POWERSHELL_51_OR_COMPILED_SETUP_MISSING' + } + $pe=[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($SetupPath)) + $rs=$pe.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$rs){throw 'R337_COMPILED_PE_SENSOR_RESOURCE_MISSING'} + $reader=[IO.StreamReader]::new($rs,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + $src=[IO.File]::ReadAllText($source) + if((& $normalize $embedded) -cne (& $normalize $src)){ + throw 'R337_COMPILED_PE_SCRIPT_NOT_EXACT' + } + $checks.Add('COMPILED_SETUP_EMBEDS_EXACT_PRODUCTION_SENSOR_HELPER') + $tok=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($source,[ref]$tok,[ref]$errors) + if(@($errors).Count){throw 'R337_PRODUCTION_PS51_SYNTAX_INVALID'} + $names=@( + 'Assert-SensorBoundaryNoReparse','Get-SensorJournalHash','Assert-SensorJournalRelative', + 'Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique','Assert-SensorDurableJournal', + 'New-SensorDurableJournal','Get-SensorJournalTerminal','Write-SensorJournalTerminal', + 'Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resume-SensorCompletedJournal','Resolve-SensorPriorTaskPrincipalSid', + 'Assert-SensorTrustedOldTaskXml','Test-SensorOwnedTaskRegistration', + 'Restore-SensorJournalPriorLayerAndTask','Resume-SensorPendingJournal' + ) + $defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names + },$true)) + if($defs.Count -ne $names.Count){throw ('R337_PRODUCTION_FUNCTION_COUNT_'+$defs.Count)} + . ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $checks.Add('EXECUTING_REAL_PS51_FUNCTIONS_EXTRACTED_FROM_COMPILED_HELPER') + $sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R337_HOSTED_INTERACTIVE_ACCOUNT_SID_INVALID'} + $name='TBME-R314-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | + Where-Object {$null -ne $_}).Count){throw 'R337_TASK_COLLISION'} + $checks.Add('NO_EXISTING_DISPOSABLE_TASK_OR_USER_SERVICE_CHANGE') + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins) + $acl.SetAccessRuleProtection($true,$false) + foreach($s in @($admins,$system)){ + $ace=[Security.AccessControl.FileSystemAccessRule]::new( + $s,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + [void]$acl.AddAccessRule($ace) + } + $trusted=Join-Path $root 'trusted' + [void][IO.Directory]::CreateDirectory($trusted,$acl) + Assert-SensorJournalTrustedAcl $trusted $true + $journal=Join-Path $trusted 'snapshot' + $live=Join-Path $root 'synthetic-broker' + $userData=Join-Path $root 'synthetic-app' + [void][IO.Directory]::CreateDirectory($userData) + $new=[string[]]@('TaskbarMonitorSensorBroker.exe','.tbme-owner.sid','vendor\source\lib.dll') + # Original protected tree DID NOT EXIST. A mid-install crash leaves an + # ownership marker and nested new bytes which must be removed on recovery. + New-SensorDurableJournal $journal $sid ([object[]]@()) '' $new $false + $checks.Add('READY_FLUSHED_BEFORE_FIRST_INSTALL_MUTATION_AND_OLD_ROOT_ABSENT') + [void][IO.Directory]::CreateDirectory($live,$acl) + [void][IO.Directory]::CreateDirectory((Join-Path $live 'vendor\source')) + [IO.File]::WriteAllText((Join-Path $live 'TaskbarMonitorSensorBroker.exe'),'NEW_TEST_ONLY') + [IO.File]::WriteAllText((Join-Path $live '.tbme-owner.sid'),$sid) + [IO.File]::WriteAllText((Join-Path $live 'vendor\source\lib.dll'),'NEW_TEST_ONLY') + if((Get-SensorJournalTerminal $journal $sid) -cne 'PREPARED'){throw 'R337_MISSING_DURABLE_READY'} + $checks.Add('SYNTHETIC_CRASH_STATE_HAS_PARTIAL_NEW_PROTECTED_FILES_AND_OWNER_MARKER') + $result=Resume-SensorPendingJournal $journal $sid $live $userData $name + if($result -cne 'PREVIOUS_LAYER_RESTORED'){throw ('R337_COLD_RECOVERY_NOT_ACCEPTED_'+$result)} + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R337_TERMINAL_JOURNAL_NOT_FULLY_RETIRED' + } + $checks.Add('PRODUCTION_COLD_REPLAY_AND_DURABLE_RETIRE_SUCCEEDED') + foreach($rel in $new){ + if([IO.File]::Exists((Join-Path $live $rel))){throw ('R337_EXPECTED_REMOVED_NEW_FILE_'+$rel)} + } + $checks.Add('ALL_KNOWN_NEW_FILES_REMOVED_AFTER_ROLLBACK') + $remaining=[IO.Directory]::Exists($live) + if($Variant -ceq 'baseline'){ + if(!$remaining){throw 'R337_BASELINE_EXPECTED_ORPHAN_NOT_REPRODUCED'} + $checks.Add('BASELINE_BROKER_DIRECTORY_ORPHAN_REPRODUCED') + }else{ + if($remaining){throw 'R337_REPAIRED_BROKER_DIRECTORY_STILL_ORPHANED'} + $checks.Add('FIRST_INSTALL_RECOVERY_LEAVES_NO_UNOWNED_BROKER_ROOT') + } + if([IO.File]::ReadAllText($outside) -cne $canary){throw 'R337_OUTSIDE_CANARY_CHANGED'} + $checks.Add('UNRELATED_CANARY_BYTE_EXACT_UNMODIFIED') + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | + Where-Object {$null -ne $_}).Count){throw 'R337_UNEXPECTED_REGISTERED_TASK'} + $checks.Add('NEVER_REGISTERED_OR_MODIFIED_SCHEDULED_TASK') +}catch{ + $failure=$_.Exception.ToString() +}finally{ + $receiptData=[ordered]@{ + Schema=1;Revision='R337';Host=$HostLabel;Variant=$Variant + SourceCommit=(git -C $ProductRoot rev-parse HEAD).Trim() + SetupSHA256=if(Test-Path -LiteralPath $SetupPath){Hash $SetupPath}else{'MISSING'} + Status=if(!$failure){if($Variant -ceq 'baseline'){'PASS_REPRODUCED_EMPTY_BROKER_ORPHAN'}else{'PASS_RECOVERY_LEAVES_NO_EMPTY_BROKER_ROOT'}}else{'FAIL'} + ActualElevatedUacPerformed=$false + RealProgramFilesWritten=$false + ActualScheduledTaskChanged=$false + ActualOwnerComputerTouched=$false + OldRootPresent=$false + EmptyBrokerRootRemained=$remaining + Checks=@($checks) + Failure=$failure + } + [IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R337_RECEIPT='+$receipt) + Write-Output ('R337_STATUS='+$receiptData.Status) +} +if($failure){throw ('R337_FIRST_INSTALL_COLD_RECOVERY_FAILED '+$failure)} diff --git a/build/Test-R338-SupervisorTrustedArgs-Native.ps1 b/build/Test-R338-SupervisorTrustedArgs-Native.ps1 new file mode 100644 index 00000000..444ae49d --- /dev/null +++ b/build/Test-R338-SupervisorTrustedArgs-Native.ps1 @@ -0,0 +1,208 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('baseline','candidate')][string]$Variant, + [Parameter(Mandatory=$true)][string]$SupervisorPath, + [Parameter(Mandatory=$true)][string]$SourceRoot, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL', + [switch]$HostedGuest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop'){throw 'R338_WINDOWS_POWERSHELL_51_REQUIRED'} +if($HostedGuest -and ($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $HostLabel -eq 'LOCAL' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID')){ + throw 'R338_GUEST_RUNNER_IDENTITY_FAIL_CLOSED' +} +function FileHash([string]$file) { + return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash +} +$expectedCommit=if($Variant -ceq 'baseline'){ + '997c6f349fa64cfe6385113a9ede46d35479cc7a' +}else{ + # Candidate must match a pinned workflow checkout; caller validates its SHA. + (git -C $SourceRoot rev-parse HEAD).Trim() +} +if((git -C $SourceRoot rev-parse HEAD).Trim() -cne $expectedCommit){ + throw 'R338_EXACT_SOURCE_HEAD_MISMATCH' +} +if(!(Test-Path -LiteralPath $SupervisorPath -PathType Leaf)){ + throw 'R338_COMPILED_SUPERVISOR_MISSING' +} +$assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SupervisorPath).Path) +$type=$assembly.GetType('TaskbarMonitorSensorSupervisor',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$parse=$type.GetMethod('ParseArgs',$flags) +$trusted=$type.GetMethod('TrustedWorkerPaths',$flags) +$brokerField=$type.GetField('BrokerPath',$flags) +if(!$parse -or !$brokerField){throw 'R338_REAL_COMPILED_ENTRYPOINT_MISSING'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$label) { + $script:checks.Add($label) + Write-Output ('PASS '+$label) +} +function Invoke-Parse([string[]]$items){ + $values=New-Object 'object[]' 1 + $values[0]=$items + return [bool]$parse.Invoke($null,$values) +} +function Invoke-Trusted([string[]]$parts){ + $values=New-Object 'object[]' 6 + for($i=0;$i -lt $values.Length;$i++){$values[$i]=$parts[$i]} + return [bool]$trusted.Invoke($null,$values) +} +$testRoot=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r338_'+$HostLabel+'_'+[Guid]::NewGuid().ToString('N')) +$reportRoot=if($HostedGuest){Join-Path $env:RUNNER_TEMP ('R338_'+$HostLabel+'_'+$Variant+'_'+$env:GITHUB_RUN_ID)}else{''} +if($HostedGuest){ + if([IO.Directory]::Exists($reportRoot)){throw 'R338_DUPLICATE_HOSTED_RECEIPT_ROOT'} + [void][IO.Directory]::CreateDirectory($reportRoot) +} +$receipt=if($HostedGuest){Join-Path $reportRoot 'R338_SUPERVISOR_ARG_TRUST.json'}else{''} +$failure='' +$canaryHash='' +$junctionCreated=$false +$brokerDir='' +$canary='' +try { + [void][IO.Directory]::CreateDirectory($testRoot) + $program=Join-Path $testRoot 'Program Files' + $brokerDir=Join-Path $program 'TaskbarMonitorEnhanced\SensorBroker' + $profile=Join-Path $testRoot 'Profile' + $local=Join-Path $profile 'AppData\Local' + $app=Join-Path $local 'TaskbarMonitorEnhanced' + $foreign=Join-Path $testRoot 'foreign-untrusted' + [void][IO.Directory]::CreateDirectory($brokerDir) + [void][IO.Directory]::CreateDirectory($app) + [void][IO.Directory]::CreateDirectory($foreign) + $broker=Join-Path $brokerDir 'TaskbarMonitorSensorBroker.exe' + $output=Join-Path $app 'cpu_temp_broker.json' + $canary=Join-Path $foreign 'CANARY_NEVER_EXECUTED.txt' + [IO.File]::WriteAllText($broker,'R338_MOCK_EXE_NEVER_EXECUTED',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($canary,'R338_UNRELATED_FOREIGN_CANARY',[Text.UTF8Encoding]::new($false)) + $canaryHash=FileHash $canary + $evil=Join-Path $foreign 'foreign.exe' + $evilOutput=Join-Path $foreign 'rogue.json' + if($Variant -ceq 'baseline'){ + if($trusted){throw 'R338_BASELINE_UNEXPECTED_TRUSTED_FUNCTION'} + if(!(Invoke-Parse ([string[]]@('--broker',$evil,'--output',$evilOutput)))){ + throw 'R338_OLD_PARSER_DID_NOT_ACCEPT_ARBITRARY_PATHS' + } + Pass 'BASELINE_COMPILED_SUPERVISOR_ACCEPTS_FOREIGN_EXE_AND_OUTPUT_WITHOUT_EXECUTING' + if(!(Invoke-Parse ([string[]]@('--broker',$evil,'--output',$evilOutput,'--ignored','x')))){ + throw 'R338_OLD_PARSER_DID_NOT_IGNORE_EXTRA_FLAGS' + } + Pass 'BASELINE_ACCEPTS_UNEXPECTED_EXTRA_FLAGS' + }else{ + if(!$trusted){throw 'R338_TRUSTED_FUNCTION_NOT_COMPILED'} + Pass 'CANDIDATE_COMPILED_SUPERVISOR_EXPOSES_TRUST_CHECK' + $argsGood=[string[]]@($broker,$output,$brokerDir,$program,$profile,$local) + if(!(Invoke-Trusted $argsGood)){throw 'R338_CANONICAL_SANDBOX_POSITIVE_REFUSED'} + Pass 'SANDBOX_REAL_PATHS_CANONICAL_KNOWNFOLDERS_ACCEPTED' + $beforeField=[string]$brokerField.GetValue($null) + $badArgs=[string[]]@('--broker',$evil,'--output',$evilOutput) + if(Invoke-Parse $badArgs){throw 'R338_FOREIGN_PROCESS_START_PATH_ACCEPTED'} + if([string]$brokerField.GetValue($null) -cne $beforeField){ + throw 'R338_INVALID_PROCESS_ARGUMENT_PARTIALLY_MUTATED_GLOBAL_PATH' + } + Pass 'COMPILLED_PRODUCTION_PARSE_REFUSES_FOREIGN_EXECUTABLE_PRELAUNCH' + $malformed=@( + [pscustomobject]@{Argv=[string[]]@('--broker',$evil,'--output',$output)}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',$evilOutput)}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--broker',$evil)}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',$output,'--ignored','x')}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output')}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',($output+'";Write-Host INJECTED'))}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',('X'*4097))} + ) + foreach($case in $malformed){ + if(Invoke-Parse $case.Argv){throw 'R338_BAD_REAL_PROCESS_ARG_ACCEPTED'} + } + Pass 'REAL_PARSER_NEGATIVES_FOREIGN_DUPLICATE_UNKNOWN_TRUNCATED_HOSTILE_OVERLONG' + if(Invoke-Trusted ([string[]]@($evil,$output,$brokerDir,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_FOREIGN_BROKER' + } + Pass 'TRUST_CHECK_REFUSES_FOREIGN_BROKER_PATH' + if(Invoke-Trusted ([string[]]@($broker,$evilOutput,$brokerDir,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_FOREIGN_OUTPUT' + } + Pass 'TRUST_CHECK_REFUSES_FOREIGN_TELEMETRY_OUTPUT' + if(Invoke-Trusted ([string[]]@($broker,$output,$foreign,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_FOREIGN_IMAGE_DIRECTORY' + } + Pass 'TRUST_CHECK_REFUSES_IMAGE_OUTSIDE_PROGRAMFILES_SENSOR_ROOT' + if(Invoke-Trusted ([string[]]@($broker,$output,$brokerDir,$program,$profile,$foreign))){ + throw 'R338_TRUST_ALLOW_NONLOCAL_PROFILE_DIRECTORY' + } + Pass 'TRUST_CHECK_REFUSES_NONCANONICAL_PROFILE_LOCALAPPDATA' + $escapedOutput=Join-Path $foreign 'escape\cpu_temp_broker.json' + if(Invoke-Trusted ([string[]]@($broker,$escapedOutput,$brokerDir,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_PARENT_ESCAPE' + } + Pass 'TRUST_CHECK_REFUSES_OUTPUT_PARENT_ESCAPE' + $brokerBackup=$brokerDir+'_original' + [IO.Directory]::Move($brokerDir,$brokerBackup) + $outsideBroker=Join-Path $foreign 'TaskbarMonitorSensorBroker.exe' + [IO.File]::WriteAllText($outsideBroker,'R338_FOREIGN_EXE_MARKER',[Text.UTF8Encoding]::new($false)) + try { + New-Item -ItemType Junction -Path $brokerDir -Target $foreign -ErrorAction Stop|Out-Null + $junctionCreated=$true + if(Invoke-Trusted ([string[]]@($broker,$output,$brokerDir,$program,$profile,$local))){ + throw 'R338_PROTECTED_BROKER_REPARSE_NOT_REJECTED' + } + Pass 'TRUST_CHECK_REJECTS_REAL_WINDOWS_BROKER_JUNCTION' + } finally { + if($junctionCreated -and [IO.Directory]::Exists($brokerDir)){ + $att=[IO.File]::GetAttributes($brokerDir) + if(($att -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R338_BROKER_LINK_CHANGED_BEFORE_CLEANUP' + } + [IO.Directory]::Delete($brokerDir,$false) + $junctionCreated=$false + } + [IO.Directory]::Move($brokerBackup,$brokerDir) + } + if(!(Invoke-Trusted $argsGood)){throw 'R338_POSITIVE_NOT_RECOVERED_AFTER_JUNCTION'} + Pass 'VALID_PATHS_REACCEPTED_AFTER_REAL_JUNCTION_REMOVAL' + } + if((FileHash $canary) -cne $canaryHash){throw 'R338_FOREIGN_CANARY_MODIFIED'} + Pass 'FOREIGN_CANARY_BYTE_IDENTICAL_NO_PROCESS_STARTED' +} catch { + $failure=$_.Exception.ToString() +} finally { + $status=if(!$failure){if($Variant -ceq 'baseline'){ + 'PASS_REPRODUCED_OLD_SUPERVISOR_ARG_ACCEPTANCE' + }else{ + 'PASS_CANDIDATE_SUPERVISOR_TRUST_BOUNDARY' + }}else{'FAIL'} + if($HostedGuest){ + $result=[ordered]@{ + Schema=1;Revision='R338';Host=$HostLabel;Variant=$Variant; + SourceCommit=(git -C $SourceRoot rev-parse HEAD).Trim() + SupervisorSHA256=FileHash $SupervisorPath + Status=$status + ActualOriginalElevatedSupervisorStarted=$false + ActualUacConsentPromptTested=$false + ProtectedProgramFilesModified=$false + OriginalOwnerHostTouched=$false + AnyBrokerExeStarted=$false + Checks=@($checks) + Failure=$failure + } + [IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R338_RECEIPT='+$receipt) + } + if($junctionCreated -and [IO.Directory]::Exists($brokerDir)){ + if(([IO.File]::GetAttributes($brokerDir) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($brokerDir,$false) + $junctionCreated=$false + } + } + # No actual system files, protected tasks, drivers or service are modified. + if([IO.Directory]::Exists($testRoot) -and !$junctionCreated){ + [IO.Directory]::Delete($testRoot,$true) + } + Write-Output ('R338_STATUS='+$status+' CHECKS='+$checks.Count) +} +if($failure){throw ('R338_SUPERVISOR_TRUST_NEGATIVE_FAILED '+$failure)} diff --git a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 new file mode 100644 index 00000000..a4d5da34 --- /dev/null +++ b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 @@ -0,0 +1,329 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R339 native cross-account DACL/TOCTOU check is destructive ONLY to uniquely +# owned test fixtures on two short-lived github-hosted Windows VM runners. +# It does NOT test interactive UAC, the original Helper, or the owner's PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r339-two-user-protected-broker' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$'){ + throw 'R339_REFUSE_NON_DISPOSABLE_NATIVE_WINDOWS' +} +$root=(Resolve-Path -LiteralPath $CandidateRoot).Path +if((git -C $root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R339_PRODUCT_SOURCE_NOT_EXACT_RUNNING_SHA' +} +$principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R339_ALREADY_ELEVATED_HOSTED_ADMIN_REQUIRED_NO_AUTO_UAC' +} +$parentSid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +$program=[IO.Path]::GetFullPath([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) +$protectedRoot=Join-Path $program 'TaskbarMonitorEnhanced' +$brokerDir=Join-Path $protectedRoot 'SensorBroker' +$exe=Join-Path $brokerDir 'TaskbarMonitorSensorBroker.exe' +$supervisor=Join-Path $brokerDir 'TaskbarMonitorSensorSupervisor.exe' +$marker=Join-Path $brokerDir '.tbme-owner.sid' +$app=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'TaskbarMonitorEnhanced' +$cpu=Join-Path $app 'cpu_temp_broker.json' +$name='TBME339'+$HostLabel+$env:GITHUB_RUN_ID.Substring($env:GITHUB_RUN_ID.Length-7) +$shared=Join-Path $env:ProgramData ('TBME_R339_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$results=Join-Path $shared 'Results' +$authority=Join-Path $shared 'R339_AUTHORITY.json' +$workerPath=Join-Path $shared 'Test-R339-SecondAccount-Actor.ps1' +$childReceipt=Join-Path $results 'R339_B_RECEIPT.json' +$childReady=Join-Path $results 'R339_ACTOR_READY' +$out=Join-Path $env:RUNNER_TEMP ('R339_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $out 'R339_PROTECTED_PROGRAMFILES_TWO_USERS.json' +$inputBroker=Join-Path $root 'build\_out\Broker\TaskbarMonitorSensorBroker.exe' +$inputSupervisor=Join-Path $root 'build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$sourceActor=Join-Path $PSScriptRoot 'Test-R339-SecondAccount-Actor.ps1' +$ownerCreated=$false;$sharedCreated=$false;$appCreated=$false;$accountCreated=$false +$actorSid='';$child=$null;$childPid=0;$actorRead=$false;$rootRemoved=$false +$accountRemoved=$false;$sharedRemoved=$false;$appRemoved=$false +$denials=0;$concurrentChecks=0;$totalChecks=0;$failure='';$phase='PRECONDITION';$actorReceiptSha="NOT_ACCEPTED";$exitProvided=$false;$observedExit=$null +$checks=[Collections.Generic.List[string]]::new() +$initial=@{Broker="NOT_STARTED";Supervisor="NOT_STARTED";Owner="NOT_STARTED";Outside="NOT_STARTED"};$outsideCanary='' +function Hash([string]$path){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function NewOwnedDirectory([string]$path,[bool]$writeForUsers){ + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){if($writeForUsers){[Security.AccessControl.FileSystemRights]::Modify}else{[Security.AccessControl.FileSystemRights]::ReadAndExecute}}else{[Security.AccessControl.FileSystemRights]::FullControl} + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow) + [void]$acl.AddAccessRule($rule) + } + [void][IO.Directory]::CreateDirectory($path,$acl) +} +function AssertNoReparse([string]$path){ + if(([IO.File]::GetAttributes($path) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw ('R339_DISPOSABLE_FIXTURE_BECAME_REPARSE '+[IO.Path]::GetFileName($path)) + } +} +function TryRemoveProtectedFixture(){ + if(!$script:ownerCreated -or ![IO.Directory]::Exists($protectedRoot)){return $false} + AssertNoReparse $protectedRoot + AssertNoReparse $brokerDir + $entries=@([IO.Directory]::GetFileSystemEntries($protectedRoot)) + if($entries.Count -ne 1 -or $entries[0] -cne $brokerDir){throw 'R339_UNEXPECTED_PROTECTED_ROOT_MEMBER_KEEP_FOR_GUEST_DISPOSAL'} + $files=@([IO.Directory]::GetFileSystemEntries($brokerDir)) + if($files.Count -ne 3 -or @($files|Where-Object {$_ -notin @($exe,$supervisor,$marker)}).Count){ + throw 'R339_UNEXPECTED_BROKER_FOLDER_MEMBER_KEEP_FOR_GUEST_DISPOSAL' + } + foreach($file in @($exe,$supervisor,$marker)){ + AssertNoReparse $file + if($file -eq $exe -and (Hash $file) -cne $initial.Broker){throw 'R339_BROKER_MUTATED_KEEP_EVIDENCE'} + if($file -eq $supervisor -and (Hash $file) -cne $initial.Supervisor){throw 'R339_SUPERVISOR_MUTATED_KEEP_EVIDENCE'} + } + foreach($file in @($exe,$supervisor,$marker)){[IO.File]::Delete($file)} + [IO.Directory]::Delete($brokerDir,$false) + [IO.Directory]::Delete($protectedRoot,$false) + return !$([IO.Directory]::Exists($protectedRoot)) +} +try{ + # The exact runner was verified empty immediately before Build.ps1. Its + # selftests may create an innocuous LocalAppData scratch directory. Do not + # treat that directory as an installation, nor delete preexisting bytes. + foreach($entry in @($protectedRoot,$shared,$out)){ + if(Test-Path -LiteralPath $entry){throw ('R339_PROTECTED_OR_RECEIPT_FIXTURE_PREEXISTS '+$entry)} + } + if(Test-Path -LiteralPath $app){ + if(!(Test-Path -LiteralPath $app -PathType Container) -or + @(Get-ChildItem -LiteralPath $app -Force -File -ErrorAction Stop | + Where-Object {$_.Name -in @('TaskbarMonitorEnhanced.exe','Uninstall.exe','install_state.json','config.json')}).Count -gt 0 -or + (Test-Path -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced')){ + throw 'R339_BUILD_CREATED_OR_EXISTING_REAL_INSTALLATION_REFUSED' + } + $appRemoved=$true # No new app root was created, preserve all prior scratch. + $checks.Add('PREEXISTING_BUILD_SELFTEST_LOCALAPPDATA_ROOT_PRESERVED') + } + if((Get-LocalUser -Name $name -ErrorAction SilentlyContinue) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue).Count){ + throw 'R339_PREEXISTING_ACCOUNT_OR_REAL_PRODUCT_PROCESS' + } + if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue |Where-Object {$null -ne $_}).Count){ + throw 'R339_PREEXISTING_PRODUCT_SCHEDULED_TASK' + } + foreach($file in @($inputBroker,$inputSupervisor,$sourceActor,$native)){ + if(![IO.File]::Exists($file)){throw 'R339_CANDIDATE_SOURCE_OR_SYSTEM_EXECUTABLE_MISSING'} + } + [void][IO.Directory]::CreateDirectory($out) + Check 'DISPOSABLE_RUNNER_FRESH_PRODUCT_ACCOUNT_AND_SCHEDULER_PRECONDITIONS' + $phase='CREATE_EXACT_NEW_PROTECTED_PROGRAMFILES_FIXTURE' + NewOwnedDirectory $protectedRoot $false + $ownerCreated=$true + [void][IO.Directory]::CreateDirectory($brokerDir) + [IO.File]::Copy($inputBroker,$exe) + [IO.File]::Copy($inputSupervisor,$supervisor) + [IO.File]::WriteAllText($marker,$parentSid,[Text.UTF8Encoding]::new($false)) + $initial=@{Broker=Hash $exe;Supervisor=Hash $supervisor;Owner=Hash $marker} + if($initial.Broker -cne (Hash $inputBroker) -or $initial.Supervisor -cne (Hash $inputSupervisor)){ + throw 'R339_PRIVATE_COMPILED_SOURCE_BYTES_NOT_EXACT' + } + $dirAcl=Get-Acl -LiteralPath $brokerDir + $exeAcl=Get-Acl -LiteralPath $exe + if([string]$dirAcl.Owner -notmatch '(?i)Administrators' -or [string]$exeAcl.Owner -notmatch '(?i)Administrators'){ + throw 'R339_PROTECTED_FIXTURE_NOT_ADMINISTRATOR_OWNED' + } + foreach($acl in @($dirAcl,$exeAcl)){ + foreach($ace in @($acl.Access|Where-Object{$_.IdentityReference.Value -match '(?i)BUILTIN\\Users'})){ + if($ace.FileSystemRights.ToString() -match '(?i)Write|Modify|FullControl|Delete|ChangePermissions|TakeOwnership'){ + throw 'R339_USERS_HAVE_WRITE_ON_PROTECTED_FIXTURE' + } + } + } + Check 'REAL_PROGRAMFILES_PROTECTED_ACL_NO_OTHER_USERS_WRITE_AND_COMPILED_BINARY_BYTES_PINNED' + $phase='PREPARE_SCOPED_OTHER_ACCOUNT_SCRATCH' + NewOwnedDirectory $shared $false + $sharedCreated=$true + NewOwnedDirectory $results $true + [IO.File]::Copy($sourceActor,$workerPath) + if((Hash $workerPath) -cne (Hash $sourceActor)){throw 'R339_WORKER_COPY_CHANGED_BEFORE_CREDENTIAL_RUN'} + if(-not [IO.Directory]::Exists($app)){ + [void][IO.Directory]::CreateDirectory($app) + $appCreated=$true + } + $outsideCanary=Join-Path $out 'FOREIGN_CANARY.txt' + [IO.File]::WriteAllText($outsideCanary,'R339_OUTSIDE_CANARY_UNCHANGED',[Text.UTF8Encoding]::new($false)) + $initial.Outside=Hash $outsideCanary + Check 'ONLY_WHITELISTED_PROGRAMFILES_AND_PROGRAMDATA_FIXTURES_CREATED' + $phase='CREATE_ACTUAL_OTHER_LOCAL_WINDOWS_SID' + $bytes=[byte[]]::new(32) + $rng=[Security.Cryptography.RandomNumberGenerator]::Create() + try{$rng.GetBytes($bytes)}finally{$rng.Dispose()} + $secret=[Convert]::ToBase64String($bytes)+'A2!' + $secure=ConvertTo-SecureString $secret -AsPlainText -Force + $credential=[Management.Automation.PSCredential]::new(($env:COMPUTERNAME+'\'+$name),$secure) + $secret=$null + $account=New-LocalUser -Name $name -Password $secure -AccountNeverExpires -Description 'R339 disposable Windows ACL test' + $accountCreated=$true + $actorSid=[string]$account.SID.Value + if($actorSid -cnotmatch '^S-1-5-21-' -or $actorSid -ceq $parentSid){throw 'R339_SECOND_WINDOWS_ACCOUNT_NOT_DISTINCT'} + Check 'REAL_OTHER_LOCAL_NONADMIN_SID_CREATED_ON_DISPOSABLE_HOST' + $authorityData=[ordered]@{ + Revision='R339';SourceCommit=$env:GITHUB_SHA;HostLabel=$HostLabel + HostComputerName=$env:COMPUTERNAME;ParentSid=$parentSid + ExpectedActorSid=$actorSid;ProtectedBrokerPath=$exe + ExpectedBrokerSHA256=$initial.Broker;ExpectedSupervisorSHA256=$initial.Supervisor + } + [IO.File]::WriteAllText($authority,($authorityData|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $phase='COMPILLED_PRODUCTION_TRUST_PATH_BEFORE_ACTOR' + $assembly=[Reflection.Assembly]::LoadFrom($inputSupervisor) + $type=$assembly.GetType('TaskbarMonitorSensorSupervisor',$true) + $method=$type.GetMethod('TrustedWorkerPaths',[Reflection.BindingFlags]'NonPublic,Static') + if(!$method){throw 'R339_PRODUCTION_SOURCE_TRUST_METHOD_MISSING'} + $args=New-Object 'object[]' 6 + $values=[string[]]@($exe,$cpu,$brokerDir,$program,[Environment]::GetFolderPath('UserProfile'), + [Environment]::GetFolderPath('LocalApplicationData')) + for($i=0;$i -lt $values.Length;$i++){$args[$i]=$values[$i]} + if(!([bool]$method.Invoke($null,$args))){throw 'R339_PRODUCTION_SUPERVISOR_PROTECTED_KNOWN_PATH_NOT_ACCEPTED'} + Check 'EXACT_COMPILED_PRODUCTION_TRUST_GUARD_ACCEPTS_OWN_PROTECTED_FIXTURE' + $phase='START_ACTUAL_OTHER_NONADMIN_ACCOUNT' + $arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$workerPath+'" -AuthorityPath "'+$authority+'" -ReceiptPath "'+$childReceipt+'"' + $child=Start-Process -FilePath $native -ArgumentList $arguments -Credential $credential -LoadUserProfile -PassThru -WindowStyle Hidden -WorkingDirectory $shared -ErrorAction Stop + if(!$child){throw 'R339_DIFFERENT_USER_PROCESS_FAILED_TO_START'} + $childPid=$child.Id + $started=[Diagnostics.Stopwatch]::StartNew() + while(![IO.File]::Exists($childReady) -and !$child.HasExited -and $started.Elapsed.TotalSeconds -lt 25){ + Start-Sleep -Milliseconds 100 + } + if(![IO.File]::Exists($childReady)){throw 'R339_OTHER_LOCAL_ACTOR_DID_NOT_REACH_MUTATION_CHECKPOINT'} + Check 'OTHER_SID_ACTOR_REACHED_CONCURRENT_MUTATION_CHECKPOINT' + $phase='ADMIN_PRODUCTION_GUARD_WHILE_OTHER_SID_ATTEMPTS_MUTATION' + while(!$child.HasExited -and $concurrentChecks -lt 100){ + if(!([bool]$method.Invoke($null,$args))){throw 'R339_TRUST_GUARD_LOST_VALID_PROTECTED_PATH_DURING_ATTACK'} + if((Hash $exe) -cne $initial.Broker -or (Hash $supervisor) -cne $initial.Supervisor){ + throw 'R339_BINARY_DRIFT_DURING_OTHER_NONADMIN_SID_ATTACK' + } + $concurrentChecks++ + Start-Sleep -Milliseconds 30 + } + if(!$child.WaitForExit(25000)){throw 'R339_SECOND_USER_ATTACK_PROCESS_TIMEOUT'} + # Native Windows Start-Process -Credential/-LoadUserProfile can yield a + # Process wrapper with a null ExitCode even after WaitForExit succeeds. + # A nonzero provided ExitCode is fatal. Missing code requires successful + # WaitForExit AND independently verified exact-SID typed guest receipt, + # 420 actual operation refusals and unchanged protected binary hashes. + $observedExit=$child.ExitCode + $exitProvided=($null -ne $observedExit) + if($exitProvided -and [int]$observedExit -ne 0){ + throw ('R339_SECOND_USER_RETURNED_NONZERO_'+$observedExit) + } + if($concurrentChecks -lt 1){throw 'R339_NO_CONCURRENT_OWNER_GUARD_WITNESS'} + Check 'ADMIN_REAL_COMPILED_SUPERVISOR_TRUST_READS_DURING_ACTUAL_OTHER_USER_ATTACK' + $phase='VERIFY_OTHER_USER_TYPED_RECEIPT_AND_BOUNDARIES' + if(![IO.File]::Exists($childReceipt)){throw 'R339_OTHER_ACCOUNT_RECEIPT_NOT_RETURNED'} + $guest=Get-Content -LiteralPath $childReceipt -Raw|ConvertFrom-Json + if($guest.Status -cne 'PASS_SECOND_REAL_LOCAL_SID_DENIED_420_PROTECTED_MUTATIONS' -or + $guest.ActorSid -cne $actorSid -or + $guest.ParentSid -cne $parentSid -or + $guest.ActualDifferentLocalAccount -ne $true -or + $guest.ActorIsAdmin -ne $false -or + $guest.CanReadProtectedBroker -ne $true -or + $guest.SourceCommit -cne $env:GITHUB_SHA -or + $guest.ActualUacPromptTested -ne $false -or + @($guest.Checks).Count -ne 3 -or + @($guest.DeniedByOperation.PSObject.Properties.Value |Where-Object{$_ -ne 60}).Count -gt 0){ + throw ('R339_DIFFERENT_TOKEN_RECEIPT_NEGATIVE_FAIL '+($guest|ConvertTo-Json -Depth 3 -Compress)) + } + $actorRead=$true + $actorReceiptSha=Hash $childReceipt + $denials=420 + if((Hash $exe) -cne $initial.Broker -or (Hash $supervisor) -cne $initial.Supervisor -or + (Hash $marker) -cne $initial.Owner -or + (Hash $outsideCanary) -cne $initial.Outside){ + throw 'R339_PROTECTED_FILES_OR_EXTERNAL_CANARY_CHANGED_BY_OTHER_SID' + } + Check '420_OTHER_SID_PROTECTED_RENAME_OVERWRITE_DELETE_LINK_OPERATIONS_DENIED_AND_HASHES_STABLE' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($child){ + try{if(!$child.HasExited){$child.Kill();[void]$child.WaitForExit(8000)}}catch{} + $child.Dispose() + } + if($accountCreated){ + try{ + $accountNow=Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if($accountNow -and [string]$accountNow.SID.Value -ceq $actorSid){ + Disable-LocalUser -Name $name -ErrorAction Stop + Remove-LocalUser -Name $name -ErrorAction Stop + $accountRemoved=($null -eq (Get-LocalUser -Name $name -ErrorAction SilentlyContinue)) + } + }catch{ $failure+= ' ACCOUNT_CLEANUP_FAILED='+$_.Exception.Message} + } + if($ownerCreated){ + try{$rootRemoved=TryRemoveProtectedFixture} + catch{$failure+=' PROTECTED_FIXTURE_CLEANUP_REFUSED='+$_.Exception.Message} + } + if($appCreated){ + try{ + if([IO.Directory]::Exists($app) -and @([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + $appRemoved=(![IO.Directory]::Exists($app)) + }catch{$failure+=' DISPOSABLE_LOCALAPP_CLEANUP_REFUSED='+$_.Exception.Message} + } + if($sharedCreated){ + try{ + foreach($f in @($childReady,$childReceipt,$authority,$workerPath)){ + if([IO.File]::Exists($f)){[IO.File]::Delete($f)} + } + if([IO.Directory]::Exists($results)){[IO.Directory]::Delete($results,$false)} + if([IO.Directory]::Exists($shared)){[IO.Directory]::Delete($shared,$false)} + $sharedRemoved=(![IO.Directory]::Exists($shared)) + }catch{$failure+=' DISPOSABLE_SHARE_CLEANUP_REFUSED='+$_.Exception.Message} + } + if($accountCreated -and !$accountRemoved){$failure+=' OTHER_SID_ACCOUNT_CLEANUP_INCOMPLETE'} + if($ownerCreated -and !$rootRemoved){$failure+=' PROTECTED_TEST_ROOT_CLEANUP_INCOMPLETE'} + if($sharedCreated -and !$sharedRemoved){$failure+=' PROGRAMDATA_FIXTURE_CLEANUP_INCOMPLETE'} + if($appCreated -and !$appRemoved){$failure+=' CREATED_USER_APP_ROOT_CLEANUP_INCOMPLETE'} + $status=if(!$failure -and $actorRead -and $denials -eq 420 -and $checks.Count -eq $(if($appCreated){8}else{9}) -and $concurrentChecks -ge 1){ + 'PASS_REAL_NONADMIN_SECOND_SID_PROTECTED_PROGRAMFILES_CONCURRENT_REFUSAL' + }else{'FAIL_CLOSED_DISPOSABLE_WINDOWS_ONLY'} + $data=[ordered]@{ + Schema=1;Revision='R339';Status=$status;SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + HostType='github-hosted-disposable-Windows' + ActorAAlreadyAdmin=$true;ActorBSID=$actorSid;ActorASID=$parentSid + RealDistinctSecondNonAdminAccount=$actorRead;DeniedMutationCount=$denials;AttemptedOperationKinds=7 + ActualProductionTrustGuardConcurrentChecks=$concurrentChecks + OtherAccountNativeProcessExitCodeProvided=$exitProvided + OtherAccountNativeProcessExitCode=$observedExit + OtherAccountReceiptSha256=$actorReceiptSha + ProtectedBrokerSHA256=$initial.Broker;ProtectedSupervisorSHA256=$initial.Supervisor + WindowsProtectedProgramFilesFixtureWasCreated=$ownerCreated + PreExistingBuildOnlyLocalAppRootRetained=(!$appCreated -and [IO.Directory]::Exists($app)) + WindowsProtectedProgramFilesFixtureRemoved=$rootRemoved + DisposableSecondAccountRemoved=$accountRemoved;DisposableProgramDataRemoved=$sharedRemoved + DisposableProfileAppRootCreatedByTest=$appCreated + DisposableProfileAppRootRemovedIfCreated=($appCreated -and $appRemoved) + TrueInteractiveUacConsentTested=$false + OriginalElevatedSensorHelperStartedOrKilled=$false + OriginalOwnerComputerTouched=$false;OriginalReleaseOrDriversModified=$false + Checks=@($checks);Failure=$failure + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + if([IO.Directory]::Exists($out)){ + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ('R339_RECEIPT='+$receipt) + } + Write-Output ('R339_RESULT='+$status+' CHECKS='+$checks.Count+' DENIALS='+$denials+' CONCURRENT='+$concurrentChecks) +} +if($failure){throw ('R339_CROSS_ACCOUNT_NATIVE_REFUSAL_NOT_ACCEPTED '+$failure)} diff --git a/build/Test-R339-SecondAccount-Actor.ps1 b/build/Test-R339-SecondAccount-Actor.ps1 new file mode 100644 index 00000000..8c324d69 --- /dev/null +++ b/build/Test-R339-SecondAccount-Actor.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$AuthorityPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R339 only runs as the actual newly-created *nonadmin* SID on an isolated +# GitHub-hosted Windows runner. It never executes a SensorBroker or installer. +if($env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R339_SECOND_ACCOUNT_REFUSE_KNOWN_OWNER_MACHINE' +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +$principal=[Security.Principal.WindowsPrincipal]::new($identity) +if($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R339_SECOND_ACCOUNT_MUST_NOT_BE_ADMIN' +} +$sid=[string]$identity.User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'R339_NONADMIN_SID_EXPECTED' +} +$program=[IO.Path]::GetFullPath([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) +$expectedProgram=Join-Path $program 'TaskbarMonitorEnhanced' +$expectedBrokerDir=Join-Path $expectedProgram 'SensorBroker' +$expectedExe=Join-Path $expectedBrokerDir 'TaskbarMonitorSensorBroker.exe' +$expectedSupervisor=Join-Path $expectedBrokerDir 'TaskbarMonitorSensorSupervisor.exe' +$allowedSharedRoot=[IO.Path]::GetFullPath((Join-Path $env:ProgramData 'R339_' )) +$authority=[IO.Path]::GetFullPath($AuthorityPath) +$parentDir=[IO.Path]::GetDirectoryName($authority) +$resultsDir=Join-Path $parentDir 'Results' +$expectedReceipt=Join-Path $resultsDir 'R339_B_RECEIPT.json' +if($parentDir -cnotmatch '[/\\]TBME_R339_[AB]_\d{5,20}$' -or + ![string]::Equals($authority,(Join-Path $parentDir 'R339_AUTHORITY.json'),[StringComparison]::OrdinalIgnoreCase) -or + ![string]::Equals([IO.Path]::GetFullPath($ReceiptPath),$expectedReceipt,[StringComparison]::OrdinalIgnoreCase) -or + -not $parentDir.StartsWith(($env:ProgramData.TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R339_SECOND_ACCOUNT_AUTHORITY_OUTSIDE_OWNED_PROGRAMDATA' +} +if(!(Test-Path -LiteralPath $authority -PathType Leaf) -or + !(Test-Path -LiteralPath $expectedExe -PathType Leaf) -or + !(Test-Path -LiteralPath $expectedSupervisor -PathType Leaf)){ + throw 'R339_SECOND_ACCOUNT_AUTHORITY_OR_PROTECTED_FILES_ABSENT' +} +$j=Get-Content -LiteralPath $authority -Raw|ConvertFrom-Json +if($j.Revision -cne 'R339' -or $j.ExpectedActorSid -cne $sid -or + $j.HostComputerName -cne $env:COMPUTERNAME -or + $j.ProtectedBrokerPath -cne $expectedExe -or + $j.SourceCommit -cnotmatch '^[0-9a-f]{40}$'){ + throw 'R339_SECOND_ACCOUNT_AUTHORITY_NOT_BOUND_TO_TOKEN_AND_ROOT' +} +if(Test-Path -LiteralPath $expectedReceipt){throw 'R339_ACTOR_RECEIPT_PREEXISTS'} +$denials=[ordered]@{ + ProtectedRootRename=0;BrokerFolderRename=0; + BrokerExecutableOverwrite=0;BrokerExecutableDelete=0; + UnexpectedChildDirectory=0;UnexpectedJunction=0; + SupervisorExecutableOverwrite=0 +} +$failure='' +$canReadProtected=$false +$checks=[Collections.Generic.List[string]]::new() +$beforeExe='' +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function RefuseMutation([string]$label,[scriptblock]$action){ + try{ + & $action | Out-Null + throw ('R339_ACTOR_WRITE_SUCCEEDED_'+$label) + }catch{ + if($_.Exception.Message.StartsWith('R339_ACTOR_WRITE_SUCCEEDED_',[StringComparison]::Ordinal)){ + throw + } + $reason=$_.Exception + $allowed=$false + while($reason){ + if($reason -is [UnauthorizedAccessException] -or + $reason -is [Security.SecurityException] -or + $reason.HResult -eq -2147024891 -or + ($reason -is [IO.IOException] -and $reason.Message -match '(?i)denied|permission|access|privilege')){ + $allowed=$true;break + } + $reason=$reason.InnerException + } + if(!$allowed){ + throw ('R339_UNEXPECTED_MUTATION_FAILURE_TYPE_'+$label+' '+$_.Exception.Message) + } + $script:denials[$label]=[int]$script:denials[$label]+1 + } +} +try{ + if((Hash $expectedExe) -cne [string]$j.ExpectedBrokerSHA256 -or + (Hash $expectedSupervisor) -cne [string]$j.ExpectedSupervisorSHA256){ + throw 'R339_ACTOR_CANNOT_READ_EXACT_PROTECTED_FILES' + } + $canReadProtected=$true + $beforeExe=Hash $expectedExe + $checks.Add('ACTUAL_OTHER_LOCAL_WINDOWS_SID_NONADMIN_READS_PROTECTED_BINARY') + # This checkpoint lets the owning high-integrity test actor begin its + # in-memory production guard reads before the attempts start. + [IO.File]::WriteAllText((Join-Path $resultsDir 'R339_ACTOR_READY'),'SECOND_USER_BOUND',[Text.UTF8Encoding]::new($false)) + Start-Sleep -Milliseconds 450 + for($i=0;$i -lt 60;$i++){ + RefuseMutation 'ProtectedRootRename' { + [IO.Directory]::Move($expectedProgram,($expectedProgram+'_foreign')) + } + RefuseMutation 'BrokerFolderRename' { + [IO.Directory]::Move($expectedBrokerDir,($expectedBrokerDir+'_foreign')) + } + RefuseMutation 'BrokerExecutableOverwrite' { + [IO.File]::WriteAllText($expectedExe,'UNAUTHORIZED_R339_WRITE') + } + RefuseMutation 'BrokerExecutableDelete' { + [IO.File]::Delete($expectedExe) + } + RefuseMutation 'UnexpectedChildDirectory' { + [void][IO.Directory]::CreateDirectory((Join-Path $expectedBrokerDir 'R339_UNAUTHORISED_SUBDIR')) + } + RefuseMutation 'UnexpectedJunction' { + New-Item -ItemType Junction -Path (Join-Path $expectedBrokerDir 'R339_UNAUTHORISED_LINK') -Target $resultsDir -ErrorAction Stop | Out-Null + } + RefuseMutation 'SupervisorExecutableOverwrite' { + [IO.File]::WriteAllText($expectedSupervisor,'UNAUTHORIZED_R339_SUPERVISOR_WRITE') + } + if($i%10 -eq 0){Start-Sleep -Milliseconds 15} + } + if(@($denials.Values |Where-Object{$_ -ne 60}).Count){ + throw 'R339_B_ACTOR_NOT_ALL_MUTATIONS_ACCESS_DENIED' + } + $checks.Add('420_ACTUAL_NONADMIN_MUTATIONS_ALL_ACCESS_DENIED') + if((Hash $expectedExe) -cne $beforeExe -or + (Hash $expectedSupervisor) -cne [string]$j.ExpectedSupervisorSHA256){ + throw 'R339_ACTOR_PROTECTED_BYTES_CHANGED' + } + $checks.Add('OTHER_ACCOUNT_PROTECTED_EXE_BYTES_STILL_PINNED') +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message +}finally{ + $status=if(!$failure -and $checks.Count -eq 3){'PASS_SECOND_REAL_LOCAL_SID_DENIED_420_PROTECTED_MUTATIONS'}else{'FAIL_SECOND_ACCOUNT'} + $out=[ordered]@{ + Schema=1;Revision='R339';Status=$status + SourceCommit=$j.SourceCommit;Host=$j.HostLabel + ActorSid=$sid;ActorIsAdmin=$false;ParentSid=$j.ParentSid + ActualDifferentLocalAccount=($sid -cne $j.ParentSid) + CanReadProtectedBroker=$canReadProtected;DeniedByOperation=$denials + ProtectedBrokerHashBefore=$beforeExe;ExpectedBrokerSHA256=$j.ExpectedBrokerSHA256 + Iterations=60;Checks=@($checks);Failure=$failure + ActualUacPromptTested=$false;ActualOriginalHelperStarted=$false + OwnerMachineTouched=$false + } + [IO.File]::WriteAllText($expectedReceipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R339_OTHER_USER_STATUS='+$status) +} +if($failure){throw ('R339_ACTOR_MUTATION_REFUSAL_FAILED '+$failure)} diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 new file mode 100644 index 00000000..0a29b737 --- /dev/null +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -0,0 +1,601 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot, + [switch]$KillBeforeMutation, + [switch]$KillAfterTaskRemoval, + [switch]$KillAfterBrokerRemoval +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R340 does NOT exercise real interactive UAC or install any driver. +# Run only on fresh hosted disposable Windows (the hosted runner already owns +# an administrator token), never on the maintainer desktop or existing sensor. +if(($KillBeforeMutation -and $KillAfterTaskRemoval) -or + ($KillBeforeMutation -and $KillAfterBrokerRemoval) -or + ($KillAfterTaskRemoval -and $KillAfterBrokerRemoval)){ + throw 'R343_DEATH_CHECKPOINTS_MUTUALLY_EXCLUSIVE' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + ![string]::Equals((git -C $SourceRoot rev-parse HEAD).Trim(),$env:GITHUB_SHA,[StringComparison]::Ordinal)){ + throw 'R340_REFUSE_NONDISPOSABLE_OR_NONPINNED_WINDOWS_HOST' +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($identity) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_HOSTED_ALREADY_ADMIN_TOKEN_REQUIRED_NO_UAC_BYPASS' +} +$sid=[string]$identity.User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R340_NONINTERACTIVE_TOKEN_SID_REFUSED'} +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$brokerFile=Join-Path $SourceRoot 'build\_out\Broker\TaskbarMonitorSensorBroker.exe' +$supervisorFile=Join-Path $SourceRoot 'build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe' +$helperSource=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$program=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$profile=[Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) +$local=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) +$temp=Join-Path $profile 'AppData\Local\Temp' +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$logs=Join-Path $app 'Logs' +$log=Join-Path $logs 'sensor_install.log' +$resultPath=Join-Path $logs 'sensor_install_result.json' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$broker=Join-Path $protected 'SensorBroker' +$task='TaskbarMonitorEnhanced Sensor Broker' +$owner=Join-Path $broker '.tbme-owner.sid' +$mainOwner=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$journal=Join-Path $protected '.tbme-sensor-rollback' +$rid=[guid]::NewGuid().ToString('N') +$payload=Join-Path $temp ('tbme_uninstall_'+$rid) +$receiptsRoot=Join-Path $env:RUNNER_TEMP ('R340_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptsRoot 'R340_REAL_PROTECTED_UNINSTALL_HELPER.json' +$outside=Join-Path $receiptsRoot 'OUTSIDE_CANARY.txt' +$checks=[Collections.Generic.List[string]]::new() +$createdRoot=$false;$createdTask=$false;$createdPayload=$false;$brokerRemoved=$false;$ownerHash='' +$createdApp=$false;$createdLogs=$false +$helperExit=$null;$helperStatus='';$failure='';$phase='PRECONDITION' +$outsideHash='';$beforeB='';$beforeS='';$exportedXmlHash='' +$taskRemoved=$false;$protectedRemoved=$false;$payloadRemoved=$false +$logsRemoved=$false;$appRemoved=$false;$childPid=0 +$nativeRunas=$false;$usesRealUnmodifiedResource=$false +$deliberateKill=$false;$killedFirstPid=0;$originalTaskIntactAtKill=$false +$deliberatePostTaskKill=$false;$taskRemovedAtKill=$false;$resultAbsentAtKill=$false;$postKilledPid=0 +$deliberatePostFileKill=$false;$fileRemovedAtKill=$false;$taskGoneAtFileKill=$false;$protectedRootPresentAtFileKill=$null;$ownerMarkerPresentAtFileKill=$null;$fileKilledPid=0 +$journalColdRestored=$false;$recoveryRunPid=0;$finalCleanupRunPid=0;$recoveryTaskXmlExact=$false;$recoveryTaskTrustMatch=$false;$firstHelperStatus='';$oldOwnedBrokerHashesRestored=$false +function Hash([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} +function Check([string]$check){$script:checks.Add($check);Write-Output ('PASS '+$check)} +function TaskExists(){ + return (@(Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction SilentlyContinue| + Where-Object{$null -ne $_}).Count -ne 0) +} +function ResolveTaskSid([string]$principal){ + if([string]::IsNullOrWhiteSpace($principal)){throw 'R340_TASK_PRINCIPAL_EMPTY'} + if($principal -match '^S-1-5-(?:\d+-)+\d+$'){return $principal} + return [string]([Security.Principal.NTAccount]::new($principal)).Translate( + [Security.Principal.SecurityIdentifier]).Value +} +function NoReparse([string]$file){ + if(([IO.File]::GetAttributes($file) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw ('R340_REPARSE_IN_OWNED_FIXTURE '+[IO.Path]::GetFileName($file)) + } +} +function RemoveOwnedProtected(){ + if(!$createdRoot){return !$([IO.Directory]::Exists($protected))} + if(![IO.Directory]::Exists($protected)){return $true} + NoReparse $protected + $members=@([IO.Directory]::GetFileSystemEntries($protected)) + if($members.Count -eq 0){ + [IO.Directory]::Delete($protected,$false) + return !$([IO.Directory]::Exists($protected)) + } + if($members.Count -ne 1 -or $members[0] -cne $broker){ + throw 'R340_UNKNOWN_PROGRAMFILES_CHILD_NO_CLEANUP' + } + NoReparse $broker + $items=@([IO.Directory]::GetFileSystemEntries($broker)) + $expected=@((Join-Path $broker 'TaskbarMonitorSensorBroker.exe'), + (Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe'),$owner) + if(@($items|Where-Object{$_ -notin $expected}).Count){ + throw 'R340_FOREIGN_MEMBER_PRESERVE_PROTECTED' + } + foreach($file in $items){ + NoReparse $file + $h=Hash $file + if($file -ceq $owner){if($h -cne $script:ownerHash){throw 'R340_MARKER_NOT_OURS'}} + elseif($file -like '*SensorBroker.exe'){if($h -cne $beforeB){throw 'R340_BROKER_NOT_ORIGINAL'}} + elseif($file -like '*SensorSupervisor.exe'){if($h -cne $beforeS){throw 'R340_SUPERVISOR_NOT_ORIGINAL'}} + } + foreach($file in $items){[IO.File]::Delete($file)} + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($protected,$false) + return !$([IO.Directory]::Exists($protected)) +} +try{ + $phase='VERIFY_FRESH_GUEST_AND_SOURCE' + foreach($f in @($setup,$brokerFile,$supervisorFile,$helperSource)){if(![IO.File]::Exists($f)){throw 'R340_PRIVATE_SOURCE_FILE_MISSING'}} + if([IO.Directory]::Exists($protected) -or [IO.Directory]::Exists($payload) -or + [IO.Directory]::Exists($receiptsRoot) -or [IO.File]::Exists($mainOwner) -or + [IO.File]::Exists($log) -or [IO.File]::Exists($resultPath) -or + (TaskExists) -or @(Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker,TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R340_PREEXISTING_REAL_PRODUCT_OR_OWNED_FIXTURE_COLLISION' + } + $preexistingApp=[IO.Directory]::Exists($app) + $preexistingLog=[IO.Directory]::Exists($logs) + [void][IO.Directory]::CreateDirectory($receiptsRoot) + [IO.File]::WriteAllText($outside,'R340_UNRELATED_CANARY',[Text.UTF8Encoding]::new($false)) + $outsideHash=Hash $outside + Check 'FRESH_GITHUB_HOSTED_ADMIN_ORIGINAL_PRODUCT_TASK_AND_JOURNAL_ABSENT' + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R340_PENDING_REAL_PROTECTED_SENSOR_JOURNAL_PREEXISTS' + } + $asm=[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($setup)) + $stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R340_SETUP_EMBEDDED_UNMODIFIED_HELPER_MISSING'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($helperSource)))){ + throw 'R340_UNMODIFIED_HELPER_RESOURCE_MISMATCH' + } + $usesRealUnmodifiedResource=$true + Check 'PRIVATE_EXACT_COMPILED_SETUP_EMBEDS_REAL_UNMODIFIED_HELPER' + $phase='CREATE_BOUNDED_DISPOSABLE_PROTECTED_FIXTURE' + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($s in @($admins,$system,$users)){ + $rights=if($s -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $s,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($protected,$acl) + $createdRoot=$true + [void][IO.Directory]::CreateDirectory($broker) + $protectedB=Join-Path $broker 'TaskbarMonitorSensorBroker.exe' + $protectedS=Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe' + [IO.File]::Copy($brokerFile,$protectedB) + [IO.File]::Copy($supervisorFile,$protectedS) + [IO.File]::WriteAllText($owner,$sid,[Text.Encoding]::ASCII) + $ownerHash=Hash $owner + $beforeB=Hash $protectedB;$beforeS=Hash $protectedS + if($beforeB -cne (Hash $brokerFile) -or $beforeS -cne (Hash $supervisorFile)){ + throw 'R340_PROTECTED_BINARIES_NOT_EXACT_PRIVATE_SETUP' + } + $programAcl=Get-Acl -LiteralPath $broker + if([string]$programAcl.Owner -notmatch '(?i)Administrators'){ + throw 'R340_PROTECTED_ACL_OWNER_NOT_ADMIN' + } + Check 'REAL_PROGRAMFILES_FIXTURE_ADMIN_OWNED_EXACT_BINARIES_AND_SID_MARKER' + $phase='CREATE_OWNER_DISABLED_NEVER_EXECUTED_TASK' + $argsText='--broker "'+$protectedB+'" --output "'+(Join-Path $app 'cpu_temp_broker.json')+'"' + $xml=@" + +false$sid +$sidInteractiveTokenHighestAvailable +falsefalse +$protectedS$argsText + +"@ + Register-ScheduledTask -TaskPath '\' -TaskName $task -Xml $xml -ErrorAction Stop|Out-Null + $createdTask=$true + $readTask=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + if($readTask.Settings.Enabled -ne $false -or + (ResolveTaskSid ([string]$readTask.Principal.UserId)) -cne $sid -or + -not [string]::Equals([string]$readTask.Actions[0].Execute,$protectedS,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R340_DISABLED_TASK_NOT_EXACT_REVIEW_OWNER' + } + $exportedXmlHash=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string](Export-ScheduledTask -TaskPath '\' -TaskName $task))) + Check 'EXACT_GLOBAL_SENSOR_TASK_REGISTERED_DISABLED_WITHOUT_EXECUTION' + $phase='PREPARE_CANONICAL_USER_TEMP_AND_LOGS' + if(![IO.Directory]::Exists($app)){[void][IO.Directory]::CreateDirectory($app);$createdApp=$true} + if(![IO.Directory]::Exists($logs)){[void][IO.Directory]::CreateDirectory($logs);$createdLogs=$true} + [void][IO.Directory]::CreateDirectory($payload);$createdPayload=$true + Check 'EXACT_USER_PROFILE_LOCALAPP_TEMP_PAYLOAD_AND_LOGS_PREPARED' + $phase='LAUNCH_UNMODIFIED_SETUP_PE_RUNAS_HELPER' + $t=$asm.GetType('SetupProgram',$true) + $method=$t.GetMethod('BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'Static,NonPublic') + if(!$method){throw 'R340_SETUP_PE_RUNAS_HELPER_FACTORY_NOT_PRESENT'} + # R340-F02: unwrap PSObject arguments for private .NET Framework reflection. + $actualArgs=[string[]]@('Uninstall',$payload,$app,$sid) + $arg=New-Object 'object[]' 4 + for($i=0;$i -lt $actualArgs.Length;$i++){ + $arg[$i]=$actualArgs[$i].PSObject.BaseObject + } + $psi=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$arg) + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + if(!$psi.UseShellExecute -or $psi.Verb -cne 'runas' -or + ![string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments -notmatch '(?i)-EncodedCommand|-Command "' -or + $psi.WorkingDirectory -cne [IO.Path]::GetDirectoryName($native)){ + throw 'R340_PE_RUNAS_EXECUTABLE_OR_TRANSPORT_NOT_VERIFIED' + } + $nativeRunas=$true + # R342: bounded, in-memory high-priority witness of exact owned Task + # Scheduler's physical entry removal, *not* a process-name-based kill. + if($KillAfterTaskRemoval -or $KillAfterBrokerRemoval){ + $taskFile=if($KillAfterTaskRemoval){ + Join-Path $env:windir 'System32\Tasks\TaskbarMonitorEnhanced Sensor Broker' + }else{$protectedB} + if(![IO.File]::Exists($taskFile)){ + throw 'R343_EXPECTED_REAL_PROTECTED_WATCH_FILE_NOT_PRESENT' + } + Add-Type -TypeDefinition @' +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +public sealed class R342TaskFileRemovalKill { + public volatile bool Deleted, Killed, ResultExistedAtKill; + public volatile string Error=""; + private Thread observer; + public void Start(Process child,string taskFile,string resultFile) { + if(child==null||!File.Exists(taskFile) || String.IsNullOrWhiteSpace(resultFile)) + throw new IOException("R342_WATCH_OWNER_TASK_NOT_PRESENT"); + observer=new Thread(()=>{ + Stopwatch sw=Stopwatch.StartNew(); + while(sw.ElapsedMilliseconds<25000){ + try{ + if(child.HasExited){Error="R342_ORIGINAL_EXIT_BEFORE_TASK_DELETE";return;} + if(!File.Exists(taskFile)){ + Deleted=true; + ResultExistedAtKill=File.Exists(resultFile); + if(ResultExistedAtKill){Error="R342_RESULT_ALREADY_EXISTS_BEFORE_KILL";return;} + child.Kill(); + Killed=child.WaitForExit(9000); + if(!Killed)Error="R342_KILL_NOT_COMPLETED"; + return; + } + }catch(Exception ex){Error=ex.GetType().Name+": "+ex.Message;return;} + Thread.SpinWait(256); + } + Error="R342_TASK_FILE_DELETE_NOT_OBSERVED"; + }); + observer.IsBackground=true; + observer.Priority=ThreadPriority.Highest; + observer.Start(); + } + public bool Wait(int timeoutMs){return observer!=null&&observer.Join(timeoutMs);} +} +'@ + } + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R341_ORIGINAL_EMBEDDED_HELPER_START_RETURNED_NULL'} + if($KillAfterTaskRemoval){ + $phase='KILL_EXACT_ORIGINAL_HELPER_AFTER_SCHEDULER_TASK_MUTATION' + $first=$child;$postKilledPid=$first.Id + try{ + $killer=[R342TaskFileRemovalKill]::new() + $killer.Start($first,$taskFile,$resultPath) + if(!$killer.Wait(39000)){throw 'R342_BOUNDED_TASK_DELETE_WATCHER_TIMEOUT'} + if(!$killer.Deleted -or !$killer.Killed -or $killer.ResultExistedAtKill -or $killer.Error){ + throw ('R342_TASK_DELETE_KILL_NOT_ACCEPTABLE_'+$killer.Error) + } + if(TaskExists){throw 'R342_KILLED_BEFORE_REAL_TASK_UNREGISTER'} + if([IO.File]::Exists($resultPath)){throw 'R342_ORIGINAL_HELPER_RESULT_ALREADY_PUBLISHED'} + $deliberatePostTaskKill=$true + $taskRemovedAtKill=$true + $resultAbsentAtKill=$true + }finally{ + try{if(!$first.HasExited){$first.Kill();[void]$first.WaitForExit(8000)}}catch{} + $first.Dispose() + } + Check 'ACTUAL_ORIGINAL_HELPER_KILLED_AFTER_TASK_MUTATION_BEFORE_RESULT' + $phase='RETRY_SAME_PE_PRODUCTION_HELPER_AFTER_GLOBAL_TASK_MUTATION' + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R342_IDENTICAL_ORIGINAL_HELPER_RETRY_NOT_STARTED'} + } + if($KillBeforeMutation){ + $phase='KILL_EXACT_ORIGINAL_HELPER_AFTER_BEGIN_PRE_SHARED_MUTATION' + $first=$child + $killedFirstPid=$first.Id + try{ + $timer=[Diagnostics.Stopwatch]::StartNew() + $beginObserved=$false + while(!$first.HasExited -and $timer.ElapsedMilliseconds -lt 30000){ + if([IO.File]::Exists($log)){ + try{ + if([IO.File]::ReadAllText($log).Contains('BEGIN mode=Uninstall')){ + $beginObserved=$true + if(!$first.HasExited){ + $first.Kill() + if(!$first.WaitForExit(10000)){ + throw 'R341_EXACT_HELPER_KILL_DID_NOT_COMPLETE' + } + $deliberateKill=$true + } + break + } + }catch [IO.IOException]{ + # Retry only transient lease contention, never swallow mutation. + } + } + Start-Sleep -Milliseconds 5 + } + if(!$beginObserved -or !$deliberateKill){ + throw 'R341_UNMODIFIED_HELPER_EXITED_BEFORE_VERIFIED_KILL' + } + if(!(TaskExists) -or + ![IO.File]::Exists($protectedB) -or ![IO.File]::Exists($protectedS) -or + ![IO.File]::Exists($owner) -or + (Hash $protectedB) -cne $beforeB -or + (Hash $protectedS) -cne $beforeS -or + (Hash $owner) -cne $ownerHash){ + throw 'R341_KILL_WAS_AFTER_SHARED_TASK_OR_PROTECTED_FILES_MUTATION' + } + $originalTaskIntactAtKill=$true + }finally{ + try{if(!$first.HasExited){$first.Kill();[void]$first.WaitForExit(8000)}}catch{} + $first.Dispose() + } + Check 'ACTUAL_UNMODIFIED_HELPER_KILLED_AFTER_BEGIN_BEFORE_PROTECTED_MUTATION' + $phase='RELAUNCH_IDENTICAL_ORIGINAL_HELPER_FOR_BOUNDED_RECOVERY' + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R341_SECOND_IDENTICAL_UNMODIFIED_HELPER_NOT_STARTED'} + } + if($KillAfterBrokerRemoval){ + $phase='KILL_EXACT_ORIGINAL_HELPER_AFTER_PROTECTED_BROKER_EXE_DELETE' + $first=$child;$fileKilledPid=$first.Id + try{ + $watcher=[R342TaskFileRemovalKill]::new() + $watcher.Start($first,$taskFile,$resultPath) + if(!$watcher.Wait(39000)){throw 'R343_BOUNDED_FILE_DELETE_WATCHER_TIMEOUT'} + if(!$watcher.Deleted -or !$watcher.Killed -or $watcher.ResultExistedAtKill -or $watcher.Error){ + throw ('R343_REAL_BROKER_EXECUTABLE_DELETE_KILL_NOT_ACCEPTED_'+$watcher.Error) + } + $fileRemovedAtKill=(![IO.File]::Exists($protectedB)) + $taskGoneAtFileKill=(!(TaskExists)) + $protectedRootPresentAtFileKill=[IO.Directory]::Exists($broker) + $ownerMarkerPresentAtFileKill=[IO.File]::Exists($owner) + if(!$fileRemovedAtKill -or !$taskGoneAtFileKill -or [IO.File]::Exists($resultPath)){ + throw 'R343_ORIGINAL_HELPER_NOT_STOPPED_AFTER_BROKER_FILE_DELETE_BEFORE_RESULT' + } + $deliberatePostFileKill=$true + }finally{ + try{if(!$first.HasExited){$first.Kill();[void]$first.WaitForExit(8000)}}catch{} + $first.Dispose() + } + Check 'ORIGINAL_HELPER_KILLED_AFTER_ACTUAL_BROKER_EXECUTABLE_DELETE_BEFORE_RESULT' + $phase='RETRY_UNMODIFIED_PRODUCTION_HELPER_AFTER_PROTECTED_FILE_MUTATION' + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R343_SECOND_ORIGINAL_HELPER_PROCESS_FAILED_TO_START'} + } + try{ + $childPid=$child.Id + if(!$child.WaitForExit(55000)){ + if(!$child.HasExited){$child.Kill();[void]$child.WaitForExit(6000)} + throw 'R340_ACTUAL_HELPER_PROCESS_TIMEOUT' + } + $helperExit=$child.ExitCode + }finally{$child.Dispose()} + if([int]$helperExit -ne 0){throw ('R340_ORIGINAL_HELPER_NONZERO_EXIT_'+$helperExit)} + Check 'ACTUAL_COMPRESSED_PE_EMBEDDED_HELPER_PROCESS_RUNAS_WITHOUT_DRIVER_EXITS_ZERO' + # R344: The exact original Helper must cold-restore the hash-bound old + # protected Broker + disabled global task after interruption under PREPARED. + # The first new Helper intentionally exits DEGRADED_PREVIOUS_LAYER_RESTORED; + # only a THIRD identical original Helper run may finish requested Uninstall. + if(($KillBeforeMutation -or $KillAfterTaskRemoval -or $KillAfterBrokerRemoval) -and + [IO.File]::Exists($resultPath)){ + $intermediate=Get-Content -LiteralPath $resultPath -Raw|ConvertFrom-Json + $firstHelperStatus=[string]$intermediate.Status + if($firstHelperStatus -ceq 'DEGRADED_PREVIOUS_LAYER_RESTORED'){ + $phase='VERIFY_HASHED_PREPARED_UNINSTALL_COLD_RESTORATION' + if(!(TaskExists) -or + ![IO.File]::Exists($protectedB) -or + ![IO.File]::Exists($protectedS) -or + ![IO.File]::Exists($owner) -or + (Hash $protectedB) -cne $beforeB -or + (Hash $protectedS) -cne $beforeS -or + (Hash $owner) -cne $ownerHash){ + throw 'R344_ORIGINAL_HELPER_COLD_REPLAY_PRIOR_OWNER_FILES_MISMATCH' + } + $oldOwnedBrokerHashesRestored=$true + $restored=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + $recoveryTaskTrustMatch=($restored.Settings.Enabled -eq $false -and + (ResolveTaskSid ([string]$restored.Principal.UserId)) -ceq $sid -and + @($restored.Actions).Count -eq 1 -and + [string]::Equals([string]$restored.Actions[0].Execute,$protectedS,[StringComparison]::OrdinalIgnoreCase) -and + [string]::Equals([string]$restored.Actions[0].Arguments,$argsText,[StringComparison]::OrdinalIgnoreCase)) + if(!$recoveryTaskTrustMatch){throw 'R344_PRIOR_SCHEDULED_TASK_ACTION_PRINCIPAL_NOT_RESTORED'} + $priorXml=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($exportedXmlHash)) + $newXml=[string](Export-ScheduledTask -TaskPath '\' -TaskName $task -ErrorAction Stop) + $recoveryTaskXmlExact=[string]::Equals($priorXml,$newXml,[StringComparison]::Ordinal) + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R344_PREPARED_JOURNAL_NOT_RETIRED_AFTER_COLD_REPLAY' + } + $journalColdRestored=$true + $recoveryRunPid=$childPid + Check 'VERIFIED_PRIOR_BROKER_MARKER_AND_TASK_COLD_RESTORED_FROM_REAL_PREPARED_JOURNAL' + $phase='THIRD_IDENTICAL_PE_HELPER_FINISHES_REQUESTED_UNINSTALL' + $finalChild=[Diagnostics.Process]::Start($psi) + if(!$finalChild){throw 'R344_THIRD_ORIGINAL_HELPER_NOT_STARTED'} + try{ + $finalCleanupRunPid=$finalChild.Id + if($finalCleanupRunPid -eq $recoveryRunPid -or + $finalCleanupRunPid -eq $fileKilledPid -or + $finalCleanupRunPid -eq $postKilledPid){ + throw 'R344_FINAL_ORIGINAL_HELPER_PID_COLLISION' + } + if(!$finalChild.WaitForExit(55000)){ + if(!$finalChild.HasExited){$finalChild.Kill();[void]$finalChild.WaitForExit(6000)} + throw 'R344_FINAL_UNINSTALL_TIMEOUT' + } + if($finalChild.ExitCode -ne 0){throw ('R344_FINAL_UNINSTALL_NONZERO_'+$finalChild.ExitCode)} + }finally{$finalChild.Dispose()} + $childPid=$finalCleanupRunPid;$helperExit=0 + }elseif($KillAfterTaskRemoval -or $KillAfterBrokerRemoval){ + throw ('R344_TORN_UNINSTALL_DID_NOT_COLD_RESTORE_'+$firstHelperStatus) + } + } + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R344_TERMINAL_UNINSTALL_JOURNAL_RESIDUE' + } + $phase='VERIFY_REAL_PROTECTED_UNINSTALL_AND_WITNESS' + $taskRemoved=!(TaskExists) + $brokerRemoved=![IO.Directory]::Exists($broker) + if(!$taskRemoved -or !$brokerRemoved){ + throw 'R340_BROKER_ROOT_OR_EXACT_GLOBAL_TASK_NOT_UNINSTALLED' + } + Check 'ACTUAL_HELPER_REMOVES_OWNED_GLOBAL_TASK_AND_PROTECTED_BROKER_SUBFOLDER' + if(![IO.File]::Exists($resultPath) -or ![IO.File]::Exists($log)){throw 'R340_REAL_HELPER_USER_STATUS_OR_LOG_NOT_WRITTEN'} + $result=Get-Content -LiteralPath $resultPath -Raw|ConvertFrom-Json + $helperStatus=[string]$result.Status + $body=[IO.File]::ReadAllText($log) + if($helperStatus -cne 'UNINSTALLED' -or + $result.RollbackPerformed -ne $false -or + $body -notmatch 'BEGIN mode=Uninstall' -or + $body -notmatch 'UNINSTALL protected sensor layer removed'){ + throw ('R340_ORIGINAL_HELPER_UNINSTALL_RESULT_BAD_'+$helperStatus) + } + Check 'REAL_UNMODIFIED_HELPER_WRITES_EXPECTED_UNINSTALL_RESULT_AND_NATIVE_LOG_LEASE' + if((Hash $outside) -cne $outsideHash){throw 'R340_EXTERNAL_CANARY_CHANGED'} + Check 'OUTSIDE_CANARY_UNCHANGED_AND_NO_PAWNIO_INSTALL_OR_SENSOR_PROCESS_START' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # R344-F01 diagnostic snapshot is read-only and taken BEFORE cleanup. + # The nominal test must expose the actual Helper Status/Message and log + # source for an early fail-closed unwind; no profile secrets are copied. + $helperDiagnosticStatus='MISSING';$helperDiagnosticMessage='' + $helperDiagnosticLogTail='' + try{ + if([IO.File]::Exists($resultPath)){ + $diagnostic=Get-Content -LiteralPath $resultPath -Raw -ErrorAction Stop|ConvertFrom-Json + $helperDiagnosticStatus=[string]$diagnostic.Status + $helperDiagnosticMessage=[string]$diagnostic.Message + } + if([IO.File]::Exists($log)){ + $lines=@(Get-Content -LiteralPath $log -Tail 9 -ErrorAction Stop) + $helperDiagnosticLogTail=($lines -join ' | ') + if($helperDiagnosticLogTail.Length -gt 1800){ + $helperDiagnosticLogTail=$helperDiagnosticLogTail.Substring($helperDiagnosticLogTail.Length-1800) + } + } + }catch{$helperDiagnosticMessage='DIAGNOSTIC_READ_FAILED_'+$_.Exception.GetType().Name} + if($failure){ + Write-Output ('R344_DIAGNOSTIC_HELPER_STATUS='+$helperDiagnosticStatus) + Write-Output ('R344_DIAGNOSTIC_HELPER_MESSAGE='+$helperDiagnosticMessage) + Write-Output ('R344_DIAGNOSTIC_LOGTAIL='+$helperDiagnosticLogTail) + } + # Only the caller-owned fixtures are candidates for cleanup. Never force + # delete unknown shared task/root contents or preexisting user AppData files. + if($createdTask -and (TaskExists)){ + try{ + $current=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + if($current.Settings.Enabled -eq $false -and + (ResolveTaskSid ([string]$current.Principal.UserId)) -ceq $sid -and + [string]::Equals([IO.Path]::GetFullPath([string]$current.Actions[0].Execute),[IO.Path]::GetFullPath((Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe')),[StringComparison]::OrdinalIgnoreCase)){ + Unregister-ScheduledTask -TaskPath '\' -TaskName $task -Confirm:$false -ErrorAction Stop + } + }catch{$failure+=' TASK_CLEANUP_FAILED='+$_.Exception.Message} + } + $taskRemoved=!(TaskExists) + if($createdRoot){ + try{$protectedRemoved=RemoveOwnedProtected} + catch{$failure+=' PROTECTED_CLEANUP_REFUSED='+$_.Exception.Message} + } + if($createdPayload){ + try{ + if([IO.Directory]::Exists($payload) -and @([IO.Directory]::GetFileSystemEntries($payload)).Count -eq 0){ + [IO.Directory]::Delete($payload,$false) + } + $payloadRemoved=(![IO.Directory]::Exists($payload)) + }catch{$failure+=' PAYLOAD_CLEANUP_REFUSED='+$_.Exception.Message} + } + if([IO.Directory]::Exists($logs)){ + try{ + foreach($f in @($log,$resultPath)){ + if([IO.File]::Exists($f)){NoReparse $f;[IO.File]::Delete($f)} + } + if($createdLogs -and @([IO.Directory]::GetFileSystemEntries($logs)).Count -eq 0){ + [IO.Directory]::Delete($logs,$false) + } + $logsRemoved=(!$createdLogs -or ![IO.Directory]::Exists($logs)) + }catch{$failure+=' LOG_CLEANUP_REFUSED='+$_.Exception.Message} + }else{$logsRemoved=$true} + if($createdApp){ + try{ + if([IO.Directory]::Exists($app) -and @([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + $appRemoved=(![IO.Directory]::Exists($app)) + }catch{$failure+=' APP_CLEANUP_REFUSED='+$_.Exception.Message} + }else{$appRemoved=$true} + if($createdTask -and !$taskRemoved){$failure+=' GLOBAL_TASK_RESIDUE'} + if($createdRoot -and !$protectedRemoved){$failure+=' GLOBAL_PROTECTED_ROOT_RESIDUE'} + if($createdPayload -and !$payloadRemoved){$failure+=' TEMP_PAYLOAD_RESIDUE'} + if(!$logsRemoved -or !$appRemoved){$failure+=' LOCALAPP_OWNED_RESIDUE'} + $neededChecks=9 + if($KillBeforeMutation -or $KillAfterTaskRemoval -or $KillAfterBrokerRemoval){$neededChecks=10} + if($journalColdRestored){$neededChecks++} + $status=if(!$failure -and $checks.Count -eq $neededChecks -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ + if($KillAfterBrokerRemoval){'PASS_PREPARED_UNINSTALL_JOURNAL_RESTORED_AFTER_BROKER_FILE_DELETE_THEN_UNINSTALLED'} + elseif($KillAfterTaskRemoval){'PASS_KILLED_ORIGINAL_HELPER_AFTER_TASK_UNREGISTER_BEFORE_RESULT_THEN_RETRIED'} + elseif($KillBeforeMutation){'PASS_KILLED_ORIGINAL_EMBEDDED_HELPER_BEFORE_SHARED_MUTATION_THEN_RECOVERED'} + else{'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER'} + }else{'FAIL_R340_NOT_ACCEPTED'} + $out=[ordered]@{ + Schema=1;Revision=$(if($KillAfterBrokerRemoval){'R344'}elseif($KillAfterTaskRemoval){'R342'}elseif($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + SetupSHA256=if([IO.File]::Exists($setup)){Hash $setup}else{'MISSING'} + CandidateBrokerSHA256=$beforeB;CandidateSupervisorSHA256=$beforeS + RealEmbeddedHelperBytesMatchSource=$usesRealUnmodifiedResource + RealProductionRunasFactory=$nativeRunas + RunasHost='WindowsPowerShell5.1SystemDirectory' + AlreadyAdminRunner=$true;TrueInteractiveUacDialogObserved=$false + OriginalHelperProcessStarted=($childPid -gt 0);OriginalHelperPid=$childPid + OriginalHelperExitCode=$helperExit;OriginalHelperResultStatus=$helperStatus + PreCleanupHelperResultStatus=$helperDiagnosticStatus + PreCleanupHelperResultMessage=$helperDiagnosticMessage + PreCleanupHelperLogTail=$helperDiagnosticLogTail + ProtectedProgramFilesCreated=$createdRoot;ProtectedProgramFilesRemoved=$protectedRemoved + OriginalHelperBrokerSubfolderRemoved=$brokerRemoved + GlobalDisabledTaskCreated=$createdTask;GlobalDisabledTaskRemoved=$taskRemoved + ScopedUserPayloadRemoved=$payloadRemoved + ScopedTestLogsRemoved=$logsRemoved + UserProfileExistingAppPreserved=!$createdApp + ActualHelperDeliberateKillTested=[bool]($deliberateKill -or $deliberatePostTaskKill -or $deliberatePostFileKill) + DeliberateKillRequested=[bool]($KillBeforeMutation -or $KillAfterTaskRemoval -or $KillAfterBrokerRemoval) + FirstKilledOriginalHelperPid=$killedFirstPid + OriginalTaskAndBrokerUnchangedAtKill=$originalTaskIntactAtKill + RecoveryUsesIdenticalOriginalRunasProcessInfo=[bool](($deliberateKill -or $deliberatePostTaskKill -or $deliberatePostFileKill) -and $nativeRunas) + ActualHelperKilledAfterBrokerExecutableDeletion=$deliberatePostFileKill + ExactProtectedBrokerExecutableAbsentAtKill=$fileRemovedAtKill + TaskAlreadyUnregisteredAtFileKill=$taskGoneAtFileKill + ProtectedBrokerRootPresentAtFileKill=$protectedRootPresentAtFileKill + OwnerMarkerPresentAtFileKill=$ownerMarkerPresentAtFileKill + PostFileKilledOriginalHelperPid=$fileKilledPid + OriginalPreparedJournalColdRecoveryObserved=$journalColdRestored + OriginalHelperColdRecoveryFirstStatus=$firstHelperStatus + ColdRecoveryHelperPid=$recoveryRunPid + FinalOriginalHelperUninstallPid=$finalCleanupRunPid + RestoredOwnerMarkerAndProtectedBinarySHAExact=$oldOwnedBrokerHashesRestored + RestoredDisabledTaskPrincipalAndActionExact=$recoveryTaskTrustMatch + RestoredTaskExportedXmlByteExact=$recoveryTaskXmlExact + NoTerminalJournalResidueAfterFinal=$true + ActualHelperKilledAfterProtectedTaskMutation=$deliberatePostTaskKill + OriginalTaskUnregisteredAtKill=$taskRemovedAtKill + ResultAbsentAtPostTaskKill=$resultAbsentAtKill + PostTaskKilledOriginalHelperPid=$postKilledPid + AnyDriverInstallationTriggered=$false + OriginalOwnerMachineTouched=$false + Checks=@($checks);Failure=$failure + } + if([IO.Directory]::Exists($receiptsRoot)){ + [IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R340_NATIVE_RECEIPT='+$receipt) + } + Write-Output ('R340_STATUS='+$status+' CHECKS='+$checks.Count+' TASK_REMOVED='+$taskRemoved+' ROOT_REMOVED='+$protectedRemoved) +} +if($failure){throw ('R340_REAL_HELPER_UNINSTALL_NOT_ACCEPTED '+$failure)} diff --git a/build/Test-R344-JournalAcl-MutationRights-Native.ps1 b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 new file mode 100644 index 00000000..34074108 --- /dev/null +++ b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 @@ -0,0 +1,138 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$'){ + throw 'R344_ACL_TEST_REFUSES_NON_DISPOSABLE_WINDOWS' +} +if((git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R344_ACL_GUEST_EXACT_COMMIT_MISMATCH' +} +$principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R344_ACL_TEST_REQUIRES_EXISTING_ADMIN_TOKEN_NO_UAC' +} +$realScript=(Resolve-Path -LiteralPath (Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1')).Path +$setup=(Resolve-Path -LiteralPath (Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe')).Path +$raw=[IO.File]::ReadAllText($realScript) +$asm=[Reflection.Assembly]::LoadFrom($setup) +$stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R344_ACL_COMPILED_REAL_PE_HELPER_ABSENT'} +$reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) +try{$fromSetup=$reader.ReadToEnd()}finally{$reader.Dispose()} +$norm={param($text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $raw) -cne (& $norm $fromSetup)){ + throw 'R344_ACL_SOURCE_NOT_EXACT_COMPILED_PE_HELPER' +} +$tok=$null;$err=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($realScript,[ref]$tok,[ref]$err) +if(@($err).Count){throw 'R344_ACL_EMBEDDED_HELPER_PS51_AST_INVALID'} +$names=@('Assert-SensorBoundaryNoReparse','Assert-SensorJournalTrustedAcl') +$defs=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Assert-SensorJournalTrustedAcl') +},$true)) +if($defs.Count -ne 2){throw 'R344_ACL_REAL_PRODUCTION_FUNCTION_MISSING'} +. ([scriptblock]::Create(($defs|ForEach-Object{$_.Extent.Text}) -join [Environment]::NewLine)) +$container=Join-Path $env:RUNNER_TEMP ('R344Acl_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$target=Join-Path $container 'test-root' +$receipt=Join-Path $container 'R344_JOURNAL_ACL_MASK.json' +if(Test-Path -LiteralPath $container){throw 'R344_ACL_UNIQUE_TEST_ROOT_COLLISION'} +$checks=[Collections.Generic.List[string]]::new() +$failure='' +$clean=$false +$realAcl=$null +function Pass([string]$label){$script:checks.Add($label);Write-Output ('PASS '+$label)} +function GuardedExpectReject([string]$expected){ + $message='' + try{ + Assert-SensorJournalTrustedAcl $target $true + throw 'R344_ACL_DANGEROUS_ALLOW_WRITE_ACCEPTED' + }catch{ + $message=$_.Exception.Message + } + if($message -cne $expected){throw ('R344_ACL_WRONG_REJECTION_'+$message)} +} +try{ + [void][IO.Directory]::CreateDirectory($container) + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $base=[Security.AccessControl.DirectorySecurity]::new() + $base.SetOwner($admins);$base.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + [void]$base.AddAccessRule($rule) + } + [void][IO.Directory]::CreateDirectory($target,$base) + $realAcl=Get-Acl -LiteralPath $target + if($realAcl.Owner -notmatch '(?i)Administrators' -or !$realAcl.AreAccessRulesProtected){ + throw 'R344_ACL_GUEST_DOES_NOT_HAVE_EXPECTED_ADMIN_OWNER' + } + Pass 'REAL_PE_EMBEDDED_HELPER_AND_PROTECTED_ADMIN_DACL_EXACT' + Assert-SensorJournalTrustedAcl $target $true + Pass 'BUILTIN_USERS_READ_EXECUTE_ALLOWED_NOT_MISCLASSIFIED_AS_WRITE' + $rights=@([Security.AccessControl.FileSystemRights]::Modify, + [Security.AccessControl.FileSystemRights]::WriteData, + [Security.AccessControl.FileSystemRights]::Delete, + [Security.AccessControl.FileSystemRights]::ChangePermissions) + $labels=@('MODIFY','WRITE_DATA','DELETE','CHANGE_PERMISSIONS') + for($i=0;$i -lt $rights.Count;$i++){ + $candidate=Get-Acl -LiteralPath $target + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $users,$rights[$i],[Security.AccessControl.AccessControlType]::Allow) + [void]$candidate.AddAccessRule($rule) + Set-Acl -LiteralPath $target -AclObject $candidate -ErrorAction Stop + try{GuardedExpectReject 'SENSOR_JOURNAL_UNTRUSTED_WRITE_ACE'} + finally{Set-Acl -LiteralPath $target -AclObject $realAcl -ErrorAction Stop} + Assert-SensorJournalTrustedAcl $target $true + Pass ('BUILTIN_USERS_'+$labels[$i]+'_REJECTED_WITH_SAME_DACL_RESTORED') + } +}catch{$failure=$_.Exception.ToString()} +finally{ + try{ + if([IO.Directory]::Exists($target)){ + Assert-SensorBoundaryNoReparse $target + if(@([IO.Directory]::GetFileSystemEntries($target)).Count -ne 0){ + throw 'R344_ACL_TEST_ROOT_HAS_UNEXPECTED_MEMBERS' + } + if($realAcl){Set-Acl -LiteralPath $target -AclObject $realAcl -ErrorAction Stop} + [IO.Directory]::Delete($target,$false) + } + $clean=(![IO.Directory]::Exists($target)) + }catch{$failure+=' CLEANUP_FAIL '+$_.Exception.ToString()} + $expected=6 + $status=if(!$failure -and $checks.Count -eq $expected -and $clean){'PASS_REAL_COMPILED_JOURNAL_ACL_MASK_NTFS'}else{'FAIL_R344_JOURNAL_ACL_GUEST'} + $data=[ordered]@{ + Schema=1;Revision='R344';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + ExactPEEmbeddedRealProductionFunction=$true + UsersReadAndExecuteAllowed=($checks.Contains('BUILTIN_USERS_READ_EXECUTE_ALLOWED_NOT_MISCLASSIFIED_AS_WRITE')) + FourUntrustedMutationACEsRejected=($checks.Count -eq $expected) + OriginalAdminOwnedFixtureRemoved=$clean + RealOwnerComputerTouched=$false;UacPromptTested=$false + Checks=@($checks);Failure=$failure + } + if([IO.Directory]::Exists($container)){ + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 6), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R344_JOURNAL_ACL_RECEIPT='+$receipt) + } + Write-Output ('R344_JOURNAL_ACL_STATUS='+$status) +} +if($failure){throw ('R344_NATIVE_COMPILED_ACL_NEGATIVE_FAILED '+$failure)} diff --git a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 new file mode 100644 index 00000000..507eac8f --- /dev/null +++ b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 @@ -0,0 +1,245 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Native security-negative witness; tests exact PE embedded production Helper +# only on fresh temporary hosted Windows. Never run on owner's installed PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R344_NEGATIVE_REQUIRES_PINNED_DISPOSABLE_GUEST' +} +$token=[Security.Principal.WindowsIdentity]::GetCurrent() +$principal=[Security.Principal.WindowsPrincipal]::new($token) +if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R344_NEGATIVE_REQUIRES_EXISTING_ADMIN_TOKEN' +} +$sid=[string]$token.User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R344_NEGATIVE_INTERACTIVE_TOKEN_SID_MISSING'} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$root=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $root 'SensorBroker' +$brokerExe=Join-Path $broker 'TaskbarMonitorSensorBroker.exe' +$supervisorExe=Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe' +$ownerMarker=Join-Path $broker '.tbme-owner.sid' +$journal=Join-Path $root '.tbme-sensor-rollback' +$task='TaskbarMonitorEnhanced Sensor Broker' +$app=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'TaskbarMonitorEnhanced' +$logs=Join-Path $app 'Logs' +$log=Join-Path $logs 'sensor_install.log' +$result=Join-Path $logs 'sensor_install_result.json' +$temp=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'AppData\Local\Temp' +$payload=Join-Path $temp ('tbme_uninstall_'+[guid]::NewGuid().ToString('N')) +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R344Neg_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$privateBroker=Join-Path $SourceRoot 'build\_out\Broker\TaskbarMonitorSensorBroker.exe' +$privateSupervisor=Join-Path $SourceRoot 'build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe' +$helperSource=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$checks=[Collections.Generic.List[string]]::new() +$failure='';$phase='PRECONDITION' +$rootCreated=$false;$payloadCreated=$false;$appCreated=$false;$logsCreated=$false +$rootRemoved=$false;$payloadRemoved=$false;$logsRemoved=$false;$appRemoved=$false +$originalBrokerHash='';$originalSupervisorHash='';$script:startupCount=0 +$realPE=$false;$canary='';$canaryHash='' +function SHA([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +function Note([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function TaskExists(){ + return @((Get-ScheduledTask -TaskPath '\' -TaskName $task -ErrorAction SilentlyContinue |Where-Object {$null -ne $_})).Count -gt 0 +} +function RefuseReparse([string]$path){ + if(([IO.File]::GetAttributes($path) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R344_NEGATIVE_FIXTURE_REPARSE_NOT_OWNED' + } +} +function RunOriginalHelper([Diagnostics.ProcessStartInfo]$psi,[string]$reason){ + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R344_NEGATIVE_ORIGINAL_HELPER_NOT_STARTED'} + try{ + if(!$child.WaitForExit(45000)){ + if(!$child.HasExited){$child.Kill();[void]$child.WaitForExit(5000)} + throw 'R344_NEGATIVE_ORIGINAL_HELPER_TIMEOUT' + } + if($child.ExitCode -ne 0){throw ('R344_NEGATIVE_HELPER_EXIT_NONZERO_'+$child.ExitCode)} + }finally{$child.Dispose()} + $script:startupCount++ + if(![IO.File]::Exists($result) -or ![IO.File]::Exists($log)){ + throw 'R344_NEGATIVE_MISSING_REAL_HELPER_STATUS_OR_LOG' + } + $status=Get-Content -LiteralPath $result -Raw|ConvertFrom-Json + if($status.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or + $status.Message -cnotmatch [Regex]::Escape($reason)){ + throw ('R344_NEGATIVE_FOERGIGN_OWNER_GUARD_FAIL_'+$status.Status+'_'+$status.Message) + } + if(!(Select-String -LiteralPath $log -SimpleMatch ('CROSS_USER_SENSOR_SAFETY_REFUSAL reason='+$reason) -Quiet)){ + throw 'R344_NEGATIVE_REAL_HELPER_NOT_LOGGING_EXPECTED_REFUSAL' + } + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired') -or + (TaskExists) -or (SHA $brokerExe) -cne $originalBrokerHash -or + (SHA $supervisorExe) -cne $originalSupervisorHash -or (SHA $canary) -cne $canaryHash){ + throw 'R344_NEGATIVE_PROTECTED_EXECUTABLE_OR_CANARY_MUTATED' + } + Note ('EXACT_PRODUCTION_HELPER_REFUSES_'+$reason) +} +try{ + foreach($f in @($setup,$privateBroker,$privateSupervisor,$helperSource,$native)){ + if(![IO.File]::Exists($f)){throw 'R344_NEGATIVE_EXACT_PRIVATE_BUILD_FILE_MISSING'} + } + if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($receiptRoot) -or + [IO.Directory]::Exists($payload) -or [IO.File]::Exists($log) -or + [IO.File]::Exists($result) -or [IO.File]::Exists((Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + (TaskExists) -or @(Get-Process TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R344_NEGATIVE_PREEXISTING_INSTALL_OR_FIXTURE_COLLISION' + } + [void][IO.Directory]::CreateDirectory($receiptRoot) + $canary=Join-Path $receiptRoot 'CANARY.txt' + [IO.File]::WriteAllText($canary,'R344_OUTSIDE_CANARY',[Text.UTF8Encoding]::new($false)) + $canaryHash=SHA $canary + $asm=[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($setup)) + $resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$resource){throw 'R344_NEGATIVE_PE_NO_EMBEDDED_PRODUCTION_HELPER'} + $reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $norm={param($text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $norm $embedded) -cne (& $norm ([IO.File]::ReadAllText($helperSource)))){ + throw 'R344_NEGATIVE_PRIVATE_SETUP_EMBEDDED_SOURCE_NOT_EXACT' + } + $realPE=$true + Note 'ORIGINAL_PE_EMBEDDED_HELPER_SHA_BOUND_WITH_DISPOSABLE_TOKEN' + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($party in @($admins,$system,$users)){ + $rights=if($party -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $party,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($root,$acl) + $rootCreated=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::Copy($privateBroker,$brokerExe) + [IO.File]::Copy($privateSupervisor,$supervisorExe) + $originalBrokerHash=SHA $brokerExe + $originalSupervisorHash=SHA $supervisorExe + if($originalBrokerHash -cne (SHA $privateBroker) -or + $originalSupervisorHash -cne (SHA $privateSupervisor)){throw 'R344_NEGATIVE_PRIVATE_EXECUTABLE_COPY_NOT_EXACT'} + if(![IO.Directory]::Exists($app)){ + [void][IO.Directory]::CreateDirectory($app);$appCreated=$true + } + if(![IO.Directory]::Exists($logs)){ + [void][IO.Directory]::CreateDirectory($logs);$logsCreated=$true + } + [void][IO.Directory]::CreateDirectory($payload);$payloadCreated=$true + $type=$asm.GetType('SetupProgram',$true) + $factory=$type.GetMethod('BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') + if(!$factory){throw 'R344_NEGATIVE_PRODUCTION_RUNAS_FACTORY_MISSING'} + $actual=[string[]]@('Uninstall',$payload,$app,$sid) + $values=New-Object 'object[]' 4 + for($i=0;$i -lt 4;$i++){$values[$i]=$actual[$i].PSObject.BaseObject} + $psi=[Diagnostics.ProcessStartInfo]$factory.Invoke($null,$values) + if(!$psi.UseShellExecute -or $psi.Verb -cne 'runas' -or + -not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R344_NEGATIVE_NOT_ORIGINAL_SYSTEM32_RUNAS_PE_HELPER' + } + Note 'BOUNDED_ADMIN_OWNED_MARKERLESS_PROGRAMFILES_BROKER_CREATED_WITHOUT_JOURNAL_OR_TASK' + $phase='ORIGINAL_HELPER_REFUSES_MISSING_OWNER_WITHOUT_DURABLE_JOURNAL' + RunOriginalHelper $psi 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' + if([IO.File]::Exists($ownerMarker)){throw 'R344_NEGATIVE_HELPER_CREATED_MISSING_OWNER_MARKER'} + Note 'ABSENT_JOURNAL_DID_NOT_FABRICATE_SENSOR_OWNER_MARKER' + $phase='ORIGINAL_HELPER_REFUSES_FOREIGN_MARKER_WITHOUT_DURABLE_JOURNAL' + [IO.File]::WriteAllText($ownerMarker,'S-1-5-21-0-0-0-1',[Text.Encoding]::ASCII) + $markerHash=SHA $ownerMarker + RunOriginalHelper $psi 'PROTECTED_SENSOR_OWNER_MARKER_MISMATCH' + if((SHA $ownerMarker) -cne $markerHash){throw 'R344_NEGATIVE_FOREIGN_SID_MARKER_CHANGED'} + Note 'OTHER_ACCOUNT_MARKER_BYTE_EXACT_AND_NO_UNTRUSTED_SENSOR_CLEANUP' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($rootCreated -and [IO.Directory]::Exists($root)){ + try{ + RefuseReparse $root;RefuseReparse $broker + $parentMembers=@([IO.Directory]::GetFileSystemEntries($root)) + $expectedBroker=$broker + if($parentMembers.Count -ne 1 -or $parentMembers[0] -cne $expectedBroker){ + throw 'R344_NEGATIVE_UNEXPECTED_PROTECTED_ROOT_CHILD_REFUSE_CLEANUP' + } + $children=@([IO.Directory]::GetFileSystemEntries($broker)) + $allowed=@($brokerExe,$supervisorExe,$ownerMarker) + if(@($children|Where-Object{$_ -notin $allowed}).Count){ + throw 'R344_NEGATIVE_UNEXPECTED_PROTECTED_FILE_REFUSE_CLEANUP' + } + foreach($f in $children){RefuseReparse $f} + if((SHA $brokerExe) -cne $originalBrokerHash -or + (SHA $supervisorExe) -cne $originalSupervisorHash){ + throw 'R344_NEGATIVE_EXACT_PROTECTED_EXECUTABLE_HASH_CHANGED_REFUSE_CLEANUP' + } + foreach($f in $children){[IO.File]::Delete($f)} + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($root,$false) + }catch{$failure+=' PROTECTED_CLEANUP_REFUSED='+$_.Exception.Message} + } + $rootRemoved=(![IO.Directory]::Exists($root)) + if($payloadCreated){ + try{ + if([IO.Directory]::Exists($payload) -and @([IO.Directory]::GetFileSystemEntries($payload)).Count -eq 0){ + [IO.Directory]::Delete($payload,$false) + } + }catch{$failure+=' PAYLOAD_CLEANUP_REFUSED='+$_.Exception.Message} + } + $payloadRemoved=(![IO.Directory]::Exists($payload)) + if([IO.Directory]::Exists($logs)){ + try{ + foreach($f in @($log,$result)){if([IO.File]::Exists($f)){RefuseReparse $f;[IO.File]::Delete($f)}} + if($logsCreated -and @([IO.Directory]::GetFileSystemEntries($logs)).Count -eq 0){ + [IO.Directory]::Delete($logs,$false) + } + }catch{$failure+=' LOG_CLEANUP_REFUSED='+$_.Exception.Message} + } + $logsRemoved=(!$logsCreated -or ![IO.Directory]::Exists($logs)) + if($appCreated -and [IO.Directory]::Exists($app)){ + try{ + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){[IO.Directory]::Delete($app,$false)} + }catch{$failure+=' APP_CLEANUP_REFUSED='+$_.Exception.Message} + } + $appRemoved=(!$appCreated -or ![IO.Directory]::Exists($app)) + if($rootCreated -and !$rootRemoved){$failure+=' OWN_PROTECTED_ROOT_NOT_REMOVED'} + if($payloadCreated -and !$payloadRemoved){$failure+=' OWN_TEMP_PAYLOAD_NOT_REMOVED'} + if(!$logsRemoved -or !$appRemoved){$failure+=' OWN_TEST_PROFILE_LOG_REMAINS'} + $status=if(!$failure -and $checks.Count -eq 6 -and $startupCount -eq 2 -and $rootRemoved){ + 'PASS_REAL_HELPER_REFUSES_UNOWNED_AND_FOREIGN_BROKER_WITHOUT_JOURNAL' + }else{'FAIL_R344_REAL_SECURITY_NEGATIVE'} + $out=[ordered]@{ + Schema=1;Revision='R344';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Status=$status;SetupSHA256=if([IO.File]::Exists($setup)){SHA $setup}else{'MISSING'} + ActualFullPEEmbeddedOriginalHelper=$realPE;RealOriginalHelperInvocations=$startupCount + NoTrustedPreparedJournalPresent=$true;NoScheduledTaskEverRegistered=$true + ForeignAndAbsentOwnerMarkerRefused=($checks.Count -eq 6) + ProtectedOriginalBrokerSHA256=$originalBrokerHash + ProtectedOriginalSupervisorSHA256=$originalSupervisorHash + ExactOwnedProtectedRootRemoved=$rootRemoved + OriginalOwnerComputerTouched=$false + ActualInteractiveUACPromptObserved=$false + ActualProtectedDriverInstalled=$false + Checks=@($checks);Failure=$failure + } + if([IO.Directory]::Exists($receiptRoot)){ + [IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R344_NEGATIVE_RECEIPT='+$receipt) + } + Write-Output ('R344_NEGATIVE_STATUS='+$status) +} +if($failure){throw ('R344_SECURITY_NEGATIVE_NOT_ACCEPTED '+$failure)} diff --git a/build/Test-R347-OtherAdminRename-Actor.ps1 b/build/Test-R347-OtherAdminRename-Actor.ps1 new file mode 100644 index 00000000..602ff48e --- /dev/null +++ b/build/Test-R347-OtherAdminRename-Actor.ps1 @@ -0,0 +1,77 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$BrokerPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R347_ADMIN_ACTOR_REFUSES_NONHOSTED_WINDOWS' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(-not $admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R347_ADMIN_ACTOR_REQUIRES_ALREADY_ADMIN_TOKEN' +} +$pf=[IO.Path]::GetFullPath([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) +$root=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $root 'SensorBroker' +if(-not [string]::Equals([IO.Path]::GetFullPath($BrokerPath),$broker,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R347_ACTOR_BROKER_NOT_EXACT_PROTECTED_PATH' +} +$receipt=[IO.Path]::GetFullPath($ReceiptPath) +if($receipt -notlike ([IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\R347_*')){ + throw 'R347_ACTOR_RECEIPT_OUTSIDE_RUNNER_TEMP' +} +if([IO.File]::Exists($receipt)){throw 'R347_OTHER_ADMIN_RECEIPT_ALREADY_EXISTS'} +$attempts=80;$d1=0;$d2=0;$failure='' +function TryRename([string]$which,[string]$old,[string]$dest){ + try{ + [IO.Directory]::Move($old,$dest) + throw ('R347_UNEXPECTED_PRIVILEGED_DIRECTORY_RENAME_SUCCEEDED_'+$which) + }catch{ + if($_.Exception.Message.StartsWith('R347_UNEXPECTED_',[StringComparison]::Ordinal)){throw} + # .NET static Directory.Move under Windows PowerShell 5.1 wraps the + # underlying Win32 IOException in MethodInvocationException. Only unwrap + # a proven sharing/access denial; reject all unrelated native failures. + $e=$_.Exception + while($e -is [Management.Automation.MethodInvocationException] -and $e.InnerException){ + $e=$e.InnerException + } + $allowedHResult=($e.HResult -in @(-2147024864,-2147024891,-2147024863)) + $allowedMessage=($e.Message -match '(?i)sharing violation|being used by another process|access (is )?denied') + if(-not (($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException]) -and + ($allowedHResult -or $allowedMessage))){ + throw ('R347_UNEXPECTED_RENAME_FAILURE_CAUSE_'+$which+ + '_'+$e.GetType().Name+'_HRESULT_'+$e.HResult+'_MSG_'+$e.Message) + } + if([IO.Directory]::Exists($dest) -or -not [IO.Directory]::Exists($old)){ + throw ('R347_PROTECTED_PATH_CHANGED_DURING_DENIAL_'+$which) + } + if($which -eq 'Broker'){$script:d1++}else{$script:d2++} + } +} +try{ + for($i=0;$i -lt $attempts;$i++){ + TryRename 'Broker' $broker ($broker+'_R347_unexpected') + TryRename 'Product' $root ($root+'_R347_unexpected') + Start-Sleep -Milliseconds 5 + } +}catch{$failure=$_.Exception.ToString()} +$status=if(-not $failure -and $d1 -eq $attempts -and $d2 -eq $attempts){ + 'PASS_ADMIN_RENAME_DENIED_DURING_PINNED_ANCESTOR_HANDLES' +}else{'FAIL_R347_ADMIN_RENAME_ACTOR'} +[IO.File]::WriteAllText($receipt,([ordered]@{ + Schema=1;Revision='R347';Status=$status + Host=$env:COMPUTERNAME;SourceCommit=$env:GITHUB_SHA + ActorPid=$PID;ActorAlreadyAdmin=$true + AttemptsPerPath=$attempts;BrokerRenameDenied=$d1;ProductRenameDenied=$d2 + ProgramFilesProtectedPath=$broker;Failure=$failure +}|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +Write-Output ('R347_ACTOR_STATUS='+$status+' ATTEMPTS='+($d1+$d2)) +if($failure){throw ('R347_ACTOR_FAILED '+$failure)} diff --git a/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 b/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 new file mode 100644 index 00000000..2b2207d9 --- /dev/null +++ b/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 @@ -0,0 +1,180 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R347_DISPOSABLE_HOST_EXACT_SOURCE_REQUIRED' +} +$actor=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(-not $actor.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R347_ALREADY_ADMIN_WINDOWS_TOKEN_REQUIRED' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$root=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $root 'SensorBroker' +$private=Join-Path $env:RUNNER_TEMP ('R347_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $private 'R347_PINNED_DIRECTORY_LEASE.json' +$otherReceipt=Join-Path $private 'OTHER_ADMIN_RENAME_RECEIPT.json' +$outsideCanary=Join-Path $private 'OUTSIDE_CANARY.txt' +$sentinel=Join-Path $broker 'R347_KNOWN.txt' +$backup=Join-Path $root 'SensorBroker_R347_backup' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$src=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$other=Join-Path $SourceRoot 'build\Test-R347-OtherAdminRename-Actor.ps1' +$ps=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$checks=[Collections.Generic.List[string]]::new() +$lease=$null;$child=$null;$created=$false;$linked=$false +$failure='';$denials=0;$concurrent=0;$actorPid=0;$released=$false;$rejectLink=$false;$clean=$false +$canarySha='';$actorSha='' +function Hash([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +function Pass([string]$s){$script:checks.Add($s);Write-Output ('PASS '+$s)} +try{ + if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($private) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R347_PREEXISTING_PRODUCT_OR_FIXTURE_REFUSE' + } + foreach($f in @($src,$setup,$other,$ps)){if(-not [IO.File]::Exists($f)){throw 'R347_SOURCE_FILE_NOT_FOUND'}} + $asm=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$resource){throw 'R347_EMBEDDED_PRODUCTION_HELPER_ABSENT'} + $reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) + try{$body=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($v) $v.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $body) -cne (& $normalize ([IO.File]::ReadAllText($src)))){ + throw 'R347_ORIGINAL_SETUP_EMBEDDED_HELPER_NOT_SOURCE_EXACT' + } + $at=$body.IndexOf('public sealed class TBMEProtectedBrokerLease',[StringComparison]::Ordinal) + if($at -lt 0){throw 'R347_NATIVE_CLASS_NOT_EMBEDDED'} + $st=$body.LastIndexOf("Add-Type -TypeDefinition @'",$at,[StringComparison]::Ordinal) + $from=$body.IndexOf([string][char]10,$st,[StringComparison]::Ordinal) + $end=$body.IndexOf(([string][char]10+"'@"),$at,[StringComparison]::Ordinal) + if($st -lt 0 -or $from -lt 0 -or $end -le $at -or $end-$from -gt 13000){ + throw 'R347_EMBEDDED_CSHARP_BOUNDS_BAD' + } + Add-Type -TypeDefinition $body.Substring($from+1,$end-$from-1) -ErrorAction Stop + Pass 'EXACT_REAL_PE_EMBEDDED_PROTECTED_LEASE_COMPILED' + [void][IO.Directory]::CreateDirectory($private) + [IO.File]::WriteAllText($outsideCanary,'R347_OUTSIDE_CANARY_UNTOUCHED',[Text.UTF8Encoding]::new($false)) + $canarySha=Hash $outsideCanary + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($root,$acl);$created=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::WriteAllText($sentinel,'R347_KNOWN_MEMBER',[Text.UTF8Encoding]::new($false)) + if((Get-Acl $root).Owner -notmatch '(?i)Administrators'){throw 'R347_PROTECTED_OWNER_NOT_ADMIN'} + Pass 'EXACT_ADMIN_OWNED_PROGRAMFILES_TEST_ROOT_CREATED' + $lease=[TBMEProtectedBrokerLease]::new($broker) + if(-not $lease.IsPinned){throw 'R347_THREE_DIRECTORY_HANDLES_NOT_OPEN'} + Pass 'REAL_PROGRAMFILES_PRODUCT_AND_BROKER_DIR_HANDLE_LEASE_ACTIVE' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$ps + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$other+'" -BrokerPath "'+$broker+'" -ReceiptPath "'+$otherReceipt+'"' + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R347_DIFFERENT_ADMIN_PROCESS_START_FAILED'} + $actorPid=$child.Id + $watch=[Diagnostics.Stopwatch]::StartNew() + while(-not $child.HasExited -and $watch.ElapsedMilliseconds -lt 25000){ + if(-not $lease.IsPinned -or -not [IO.File]::Exists($sentinel)){ + throw 'R347_PROTECTED_ROOT_CHANGED_WHILE_HANDLE_PINNED' + } + $concurrent++;Start-Sleep -Milliseconds 4 + } + if(-not $child.WaitForExit(8000) -or $child.ExitCode -ne 0){throw 'R347_OTHER_ADMIN_PROCESS_NOT_SUCCESSFUL'} + $a=Get-Content -LiteralPath $otherReceipt -Raw|ConvertFrom-Json + if($a.Status -cne 'PASS_ADMIN_RENAME_DENIED_DURING_PINNED_ANCESTOR_HANDLES' -or + $a.ActorPid -ne $actorPid -or $a.ActorAlreadyAdmin -ne $true -or + $a.BrokerRenameDenied -ne 80 -or $a.ProductRenameDenied -ne 80 -or + $a.SourceCommit -cne $env:GITHUB_SHA -or $concurrent -lt 5){ + throw 'R347_OTHER_ADMIN_TYPED_PROOF_INVALID' + } + $actorSha=Hash $otherReceipt;$denials=160 + Pass 'OTHER_ADMIN_PID_RENAME_160_OF_160_REFUSED_DURING_LEASE' + Pass 'ORIGINAL_PINNED_BROKER_PATH_STABLE_WHILE_ATTACKER_ACTIVE' + $lease.Dispose();$lease=$null + [IO.Directory]::Move($broker,$backup) + if(-not [IO.Directory]::Exists($backup) -or [IO.Directory]::Exists($broker)){throw 'R347_POST_LEASE_MOVE_NOT_SUCCESSFUL'} + [IO.Directory]::Move($backup,$broker);$released=$true + Pass 'SAME_ADMIN_RENAME_SUCCEEDS_AFTER_LEASE_RELEASE' + [IO.Directory]::Move($broker,$backup) + New-Item -ItemType Junction -Path $broker -Target $private -ErrorAction Stop|Out-Null + $linked=$true + $message='' + try{$attempt=[TBMEProtectedBrokerLease]::new($broker);try{throw 'R347_UNSAFE_LINK_ACCEPTED'}finally{$attempt.Dispose()}} + catch{ + $cause=$_.Exception + while($cause.InnerException -and + ($cause -is [Management.Automation.MethodInvocationException] -or + $cause -is [Reflection.TargetInvocationException])){ + $cause=$cause.InnerException + } + $message=$cause.Message + } + if($message -cne 'SENSOR_BROKER_DIRECTORY_LEASE_REPARSE_REFUSED'){throw ('R347_JUNCTION_REJECTION_WRONG_'+$message)} + $rejectLink=$true + if((Hash $outsideCanary) -cne $canarySha){throw 'R347_OUTSIDE_CANARY_CHANGED'} + Pass 'REAL_NTFS_JUNCTION_REJECTED_BY_HANDLE_REPARSE_TAG' + if(([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -eq 0){throw 'R347_JUNCTION_CHANGED_BEFORE_SAFE_DELETION'} + [IO.Directory]::Delete($broker,$false);$linked=$false + [IO.Directory]::Move($backup,$broker) + if((Hash $outsideCanary) -cne $canarySha){throw 'R347_OUTSIDE_SHA_CHANGED'} + Pass 'OUTSIDE_CANARY_SHA256_UNCHANGED' +}catch{$failure=$_.Exception.ToString()} +finally{ + if($child){try{if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(3000)}}catch{};$child.Dispose()} + if($lease){$lease.Dispose()} + if($linked -and [IO.Directory]::Exists($broker)){ + try{if(([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0){[IO.Directory]::Delete($broker,$false)}}catch{$failure+=' LINK_CLEANUP_FAILED'} + } + if([IO.Directory]::Exists($backup) -and -not [IO.Directory]::Exists($broker)){try{[IO.Directory]::Move($backup,$broker)}catch{$failure+=' RESTORE_BACKUP_FAILED'}} + if($created -and [IO.Directory]::Exists($root)){ + try{ + if(([IO.File]::GetAttributes($root) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [IO.Directory]::Exists($broker) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @([IO.Directory]::GetFileSystemEntries($root)).Count -ne 1 -or + @([IO.Directory]::GetFileSystemEntries($broker)).Count -ne 1 -or + [IO.File]::ReadAllText($sentinel) -cne 'R347_KNOWN_MEMBER'){ + throw 'R347_UNKNOWN_FIXTURE_MEMBER_NO_CLEANUP' + } + [IO.File]::Delete($sentinel);[IO.Directory]::Delete($broker,$false);[IO.Directory]::Delete($root,$false) + $clean=(![IO.Directory]::Exists($root)) + }catch{$failure+=' PROTECTED_FIXTURE_CLEANUP_FAILED '+$_.Exception.Message} + } + $status=if(-not $failure -and $checks.Count -eq 8 -and $denials -eq 160 -and + $concurrent -ge 5 -and $released -and $rejectLink -and $clean){ + 'PASS_CONCURRENT_ALREADY_ADMIN_DIRECTORY_RENAME_DENIED' + }else{'FAIL_R347_DIRECTORY_LEASE'} + if([IO.Directory]::Exists($private)){ + $obj=[ordered]@{ + Schema=1;Revision='R347';Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + ExactOriginalSetupPEHelper=$true;RealOtherAdminPid=$actorPid + DeniedAdminRenames=$denials;ConcurrentPinnedChecks=$concurrent + RenameAfterDisposePass=$released;ReparseJunctionRefused=$rejectLink + OutsideCanarySHA256=$canarySha;OtherAdminRawReceiptSHA256=$actorSha + CreatedAdminProtectedRootRemoved=$clean;LeafFileConcurrentRaceProven=$false + InteractiveUACNotTested=$true;ActualOriginalHelperWasNotLaunched=$true + OwnerComputerTouched=$false;Checks=@($checks);Failure=$failure + } + [IO.File]::WriteAllText($receipt,($obj|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R347_TYPED_RECEIPT='+$receipt) + } + Write-Output ('R347_STATUS='+$status+' CHECKS='+$checks.Count+' DENIED='+$denials) +} +if($failure){throw ('R347_NATIVE_PRIVILEGED_RENAME_GUARD_FAILED '+$failure)} diff --git a/build/Test-R348-PrivilegedLeaf-Actor.ps1 b/build/Test-R348-PrivilegedLeaf-Actor.ps1 new file mode 100644 index 00000000..99858311 --- /dev/null +++ b/build/Test-R348-PrivilegedLeaf-Actor.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$AuthorityPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r348-privileged-leaf-race-negative' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R348_CHILD_ONLY_DISPOSABLE_HOSTED_WINDOWS' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R348_CHILD_ALREADY_ADMIN_REQUIRED_NO_ELEVATION_ATTEMPT' +} +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +$authority=[IO.Path]::GetFullPath($AuthorityPath) +$receipt=[IO.Path]::GetFullPath($ReceiptPath) +if(-not $authority.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or + -not $receipt.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R348_CHILD_OUTPUT_NOT_EXACT_DISPOSABLE_TEMP' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$leaf=Join-Path $pf 'TaskbarMonitorEnhanced\SensorBroker\R348_KNOWN_LEAF.txt' +$a=Get-Content -LiteralPath $authority -Raw|ConvertFrom-Json +if($a.Schema -ne 1 -or $a.Revision -cne 'R348' -or + $a.SourceCommit -cne $env:GITHUB_SHA -or + $a.Guest -cne $env:COMPUTERNAME -or + $a.ExpectedLeafPath -cne $leaf -or + $a.OriginalSha256 -cnotmatch '^[0-9A-F]{64}$' -or + [string]::IsNullOrWhiteSpace([string]$a.Payload) -or + $a.Payload.Length -gt 128 -or + $a.Payload -cnotmatch '^R348_ACTOR_WRITE_[0-9]+_[AB]$'){ + throw 'R348_CHILD_AUTHORITY_NOT_BOUND_TO_GUEST_SOURCE_LEAF' +} +if(-not [IO.File]::Exists($leaf) -or + (Get-FileHash -LiteralPath $leaf -Algorithm SHA256).Hash -cne $a.OriginalSha256){ + throw 'R348_CHILD_EXACT_OWNER_LEAF_OR_OLD_HASH_WRONG' +} +if([IO.File]::Exists($receipt)){throw 'R348_CHILD_RECEIPT_ALREADY_EXISTS'} +$failure='';$writeSucceeded=$false +try{ + [IO.File]::WriteAllText($leaf,[string]$a.Payload,[Text.UTF8Encoding]::new($false)) + $writeSucceeded=[IO.File]::ReadAllText($leaf) -ceq [string]$a.Payload + if(-not $writeSucceeded){throw 'R348_CHILD_LEAF_WRITE_READBACK_UNEXPECTED'} +}catch{$failure=$_.Exception.GetType().Name+': '+$_.Exception.Message} +$status=if(!$failure -and $writeSucceeded){'OBSERVED_PRIVILEGED_LEAF_WRITE_WHILE_DIRECTORY_LEASE_HELD'}else{'NO_LEAF_WRITE_OBSERVED_OR_ACTOR_FAILED'} +$out=[ordered]@{ + Schema=1;Revision='R348';SourceCommit=$env:GITHUB_SHA;Status=$status + Guest=$env:COMPUTERNAME;ActorPid=$PID;ActorAlreadyAdmin=$true + OriginalSHA256=$a.OriginalSha256;LeafWriteSucceeded=$writeSucceeded + AfterSHA256=$(if([IO.File]::Exists($leaf)){(Get-FileHash $leaf -Algorithm SHA256).Hash}else{'MISSING'}) + OwnerComputerTouched=$false;OriginalHelperStarted=$false + Failure=$failure +} +[IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R348_CHILD_STATUS='+$status +if($failure){throw ('R348_CHILD_TEST_OPERATION_FAILED_'+$failure)} diff --git a/build/Test-R348-PrivilegedLeaf-Native.ps1 b/build/Test-R348-PrivilegedLeaf-Native.ps1 new file mode 100644 index 00000000..b496ec08 --- /dev/null +++ b/build/Test-R348-PrivilegedLeaf-Native.ps1 @@ -0,0 +1,179 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r348-privileged-leaf-race-negative' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R348_GUEST_ONLY_HOSTED_EXACT_REVISION' +} +$who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R348_REQUIRE_EXISTING_ADMIN_NO_UAC_BYPASS' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$product=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $product 'SensorBroker' +$leaf=Join-Path $broker 'R348_KNOWN_LEAF.txt' +$runRoot=Join-Path $env:RUNNER_TEMP ('R348_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$authority=Join-Path $runRoot 'R348_AUTHORITY.json' +$actorReceipt=Join-Path $runRoot 'R348_ACTOR_RECEIPT.json' +$receipt=Join-Path $runRoot 'R348_PRIVILEGED_LEAF_NEGATIVE.json' +$outside=Join-Path $runRoot 'OUTSIDE_CANARY.txt' +$source=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$actorFile=Join-Path $SourceRoot 'build\Test-R348-PrivilegedLeaf-Actor.ps1' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$oldText='R348_BASELINE_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$newText='R348_ACTOR_WRITE_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$lease=$null;$child=$null;$protectedOwned=$false +$checks=[Collections.Generic.List[string]]::new() +$failure='';$observedGap=$false;$clean=$false;$childPid=0;$oldSha='';$newSha='';$actorSha='';$canarySha='' +function Hash([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +function Pass([string]$msg){$script:checks.Add($msg);Write-Output ('PASS '+$msg)} +try{ + if([IO.Directory]::Exists($product) -or [IO.Directory]::Exists($runRoot) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R348_EXISTING_PRODUCT_OR_GUEST_TEST_ROOT' + } + foreach($f in @($source,$setup,$actorFile,$native)){ + if(-not [IO.File]::Exists($f)){throw 'R348_REVIEW_SOURCE_FILE_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R348_COMPILED_ORIGINAL_HELPER_NOT_PRESENT'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $norm={param($v) $v.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $norm $embedded) -cne (& $norm ([IO.File]::ReadAllText($source)))){ + throw 'R348_COMPILED_PRODUCT_NOT_IDENTICAL_TO_HELPER_SOURCE' + } + $loc=$embedded.IndexOf('public sealed class TBMEProtectedBrokerLease',[StringComparison]::Ordinal) + $start=$embedded.LastIndexOf("Add-Type -TypeDefinition @'",$loc,[StringComparison]::Ordinal) + $first=$embedded.IndexOf([string][char]10,$start,[StringComparison]::Ordinal) + $last=$embedded.IndexOf(([string][char]10+"'@"),$loc,[StringComparison]::Ordinal) + if($loc -lt 0 -or $start -lt 0 -or $first -lt 0 -or $last -le $loc -or $last-$first -gt 14000){ + throw 'R348_COMPILED_LEASE_EXTRACTION_BOUNDS' + } + Add-Type -TypeDefinition $embedded.Substring($first+1,$last-$first-1) -ErrorAction Stop + if($null -eq ('TBMEProtectedBrokerLease' -as [type])){throw 'R348_COMPILED_LEASE_TYPE_NOT_AVAILABLE'} + Pass 'REAL_ORIGINAL_SETUP_PE_EMBEDDED_DIRECTORY_LEASE_CLASS' + [void][IO.Directory]::CreateDirectory($runRoot) + [IO.File]::WriteAllText($outside,'R348_OUTSIDE_CANARY',[Text.UTF8Encoding]::new($false)) + $canarySha=Hash $outside + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($product,$acl) + $protectedOwned=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::WriteAllText($leaf,$oldText,[Text.UTF8Encoding]::new($false)) + $oldSha=Hash $leaf + Pass 'TEST_ONLY_ADMIN_OWNED_PROTECTED_ROOT_EXACT_LEAF_CREATED' + $lease=[TBMEProtectedBrokerLease]::new($broker) + if(-not $lease.IsPinned){throw 'R348_EXPECT_THREE_OPEN_PROGRAMFILES_DIRECTORY_HANDLES'} + Pass 'REAL_R347_DIRECTORY_LEASE_HELD_ACROSS_OTHER_ADMIN_WRITE' + $auth=[ordered]@{ + Schema=1;Revision='R348';SourceCommit=$env:GITHUB_SHA;Guest=$env:COMPUTERNAME + ExpectedLeafPath=$leaf;OriginalSHA256=$oldSha;Payload=$newText + } + [IO.File]::WriteAllText($authority,($auth|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -AuthorityPath "'+$authority+'" -ReceiptPath "'+$actorReceipt+'"' + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R348_DIFFERENT_ADMIN_CHILD_NOT_STARTED'} + $childPid=$child.Id + if(-not $child.WaitForExit(18000)){throw 'R348_DIFFERENT_ADMIN_CHILD_TIMEOUT'} + if($child.ExitCode -ne 0){throw ('R348_DIFFERENT_ADMIN_CHILD_FAILED_EXIT_'+$child.ExitCode)} + if(-not [IO.File]::Exists($actorReceipt)){throw 'R348_CHILD_PROOF_NOT_FOUND'} + $typed=Get-Content $actorReceipt -Raw|ConvertFrom-Json + $actorSha=Hash $actorReceipt + $newSha=Hash $leaf + if($typed.SourceCommit -cne $env:GITHUB_SHA -or + $typed.Revision -cne 'R348' -or + $typed.ActorPid -ne $childPid -or + $typed.Status -cne 'OBSERVED_PRIVILEGED_LEAF_WRITE_WHILE_DIRECTORY_LEASE_HELD' -or + $typed.LeafWriteSucceeded -ne $true -or + $typed.OriginalSHA256 -cne $oldSha -or + $typed.AfterSHA256 -cne $newSha -or + [IO.File]::ReadAllText($leaf) -cne $newText -or + -not $lease.IsPinned -or + $newSha -ceq $oldSha){ + throw 'R348_REAL_PRIVILEGED_LEAF_WRITE_GAP_NOT_WITNESSED' + } + $observedGap=$true + Pass 'DIFFERENT_ADMIN_PID_CHANGED_LEAF_WHILE_DIRECTORY_HANDLE_STILL_PINNED' + if(-not [IO.Directory]::Exists($broker) -or + -not [IO.Directory]::Exists($product) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R348_DIRECTORY_IDENTITY_NO_LONGER_STABLE' + } + Pass 'DIRECTORY_ANCESTRY_STABLE_WHILE_LEAF_BYTES_CHANGED' + if((Hash $outside) -cne $canarySha){throw 'R348_OUTSIDE_CANARY_MODIFIED'} + Pass 'OUTSIDE_CANARY_UNCHANGED_BY_LEAF_WRITE' +}catch{$failure=$_.Exception.ToString()} +finally{ + if($child){ + try{if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(5000)}}catch{} + $child.Dispose() + } + if($lease){$lease.Dispose()} + if($protectedOwned -and [IO.Directory]::Exists($product)){ + try{ + if(([IO.File]::GetAttributes($product) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [IO.Directory]::Exists($broker) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @([IO.Directory]::GetFileSystemEntries($product)).Count -ne 1 -or + @([IO.Directory]::GetFileSystemEntries($broker)).Count -ne 1 -or + -not [IO.File]::Exists($leaf)){ + throw 'R348_UNEXPECTED_PROTECTED_ROOT_MEMBER' + } + $actual=[IO.File]::ReadAllText($leaf) + if($actual -cne $newText -and $actual -cne $oldText){ + throw 'R348_PROTECTED_TEST_LEAF_UNKNOWN_BYTES_PRESERVED' + } + [IO.File]::Delete($leaf) + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($product,$false) + $clean=(-not [IO.Directory]::Exists($product)) + }catch{$failure+=' TEST_ONLY_CLEANUP_REFUSED_'+$_.Exception.Message} + } + $status=if(-not $failure -and $checks.Count -eq 6 -and $observedGap -and $clean){ + 'NEGATIVE_CONFIRMED_DIRECTORY_LEASE_DOES_NOT_PROTECT_PRIVILEGED_LEAF_OVERWRITE' + }else{'FAIL_R348_DIAGNOSTIC_INCONCLUSIVE'} + if([IO.Directory]::Exists($runRoot)){ + [IO.File]::WriteAllText($receipt,([ordered]@{ + Schema=1;Revision='R348';Status=$status;SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + RealExactPEEmbeddedLeaseLoaded=$true;AdminActorPid=$childPid;ActorReceiptSHA256=$actorSha + DirectoryHandlesStayedPinned=$observedGap;PrivilegedLeafWriteWasAllowed=$observedGap + LeafSHA256Before=$oldSha;LeafSHA256After=$newSha + OutsideCanarySHA256=$canarySha;TemporaryProtectedFixtureRemoved=$clean + UserInstallationOrGlassTouched=$false;RealUACPromptTested=$false + SecurityGatePassed=$false;OriginalHelperExecuted=$false + Checks=@($checks);Failure=$failure + }|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ('R348_RAW_NEGATIVE_PROOF='+$receipt) + } + Write-Output ('R348_DIAGNOSTIC_STATUS='+$status+' CHECKS='+$checks.Count) +} +if($failure){throw ('R348_NEGATIVE_LEAF_GAP_TEST_FAILED_'+$failure)} diff --git a/build/Test-R349-PrivilegedLeaf-Actor.ps1 b/build/Test-R349-PrivilegedLeaf-Actor.ps1 new file mode 100644 index 00000000..4eab68d0 --- /dev/null +++ b/build/Test-R349-PrivilegedLeaf-Actor.ps1 @@ -0,0 +1,67 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$AuthorityPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R349_REFUSE_NONDISPOSABLE_WINDOWS' +} +$who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'R349_CHILD_ALREADY_ADMIN_REQUIRED'} +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +$auth=[IO.Path]::GetFullPath($AuthorityPath) +$receipt=[IO.Path]::GetFullPath($ReceiptPath) +if(-not $auth.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or + -not $receipt.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or + [IO.File]::Exists($receipt)){ + throw 'R349_ACTOR_AUTHORITY_OUTSIDE_TEMP_OR_RECEIPT_EXISTS' +} +$a=Get-Content -LiteralPath $auth -Raw|ConvertFrom-Json +$leaf=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'TaskbarMonitorEnhanced\SensorBroker\R349_KNOWN_LEAF.txt' +if($a.Schema -ne 1 -or $a.Revision -cne 'R349' -or $a.SourceCommit -cne $env:GITHUB_SHA -or + $a.Guest -cne $env:COMPUTERNAME -or $a.ExpectedLeafPath -cne $leaf -or + $a.Payload -cnotmatch '^R349_PRIVILEGED_WRITE_[0-9]+_[AB]$' -or + $a.OriginalSha256 -cnotmatch '^[0-9A-F]{64}$'){ + throw 'R349_ACTOR_AUTHORITY_NOT_EXACT' +} +# The parent owns a READ/WRITE file handle with only FILE_SHARE_READ; +# a separate path-based Get-FileHash may itself be refused because its +# FileShare mode does not permit the parent's WRITE access. The original SHA +# was already established before pinning and passed in typed guest authority. +if(-not [IO.File]::Exists($leaf)){ + throw 'R349_EXPECTED_EXACT_TEST_LEAF_ABSENT' +} +$writeDenied=$false;$why='';$unexpected='' +try{ + [IO.File]::WriteAllText($leaf,[string]$a.Payload,[Text.UTF8Encoding]::new($false)) + $unexpected='R349_PRIVILEGED_LEAF_OVERWRITE_SUCCEEDED_WHILE_PINNED' +}catch{ + $e=$_.Exception + while($e.InnerException -and + ($e -is [Management.Automation.MethodInvocationException] -or + $e -is [Reflection.TargetInvocationException])){$e=$e.InnerException} + $why=$e.GetType().Name+' HRESULT='+$e.HResult + $sharing=$e.HResult -in @(-2147024864,-2147024891) + if(($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException]) -and $sharing){ + $writeDenied=$true + }else{$unexpected='R349_WRONG_FAILURE_CAUSE_'+$why} +} +$status=if($writeDenied -and !$unexpected){ + 'PASS_PRIVILEGED_WRITE_OPEN_REFUSED_BY_PINNED_LEAF' +}else{'FAIL_R349_LEAF_WRITE_NOT_EXCLUSIVELY_PINNED'} +$out=[ordered]@{ + Schema=1;Revision='R349';Status=$status;SourceCommit=$env:GITHUB_SHA + ActorPid=$PID;Guest=$env:COMPUTERNAME;AlreadyAdmin=$true + WriteDeniedByRealWindowsSharing=$writeDenied;NativeError=$why + OriginalSHA256=$a.OriginalSha256 + # Cannot reopen the leaf for reading while it is pinned. Parent checks + # SHA from its already-open handle, then releases the lease. + OwnerComputerTouched=$false;Failure=$unexpected +} +[IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R349_CHILD_RESULT='+$status +if($unexpected){throw $unexpected} diff --git a/build/Test-R349-PrivilegedLeaf-Native.ps1 b/build/Test-R349-PrivilegedLeaf-Native.ps1 new file mode 100644 index 00000000..5c3278d1 --- /dev/null +++ b/build/Test-R349-PrivilegedLeaf-Native.ps1 @@ -0,0 +1,237 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R349_REFUSE_NONDISPOSABLE_OR_WRONG_GIT_SOURCE' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R349_GUEST_ALREADY_ADMIN_REQUIRED_NO_ELEVATION' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$protected=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $protected 'SensorBroker' +$leafName='R349_KNOWN_LEAF.txt' +$leaf=Join-Path $broker $leafName +$root=Join-Path $env:RUNNER_TEMP ('R349_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R349_PINNED_LEAF_NATIVE.json' +$actorReceipt=Join-Path $root 'R349_ACTOR_RECEIPT.json' +$authority=Join-Path $root 'R349_AUTHORITY.json' +$canary=Join-Path $root 'OUTSIDE_CANARY.txt' +$source=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$actorFile=Join-Path $SourceRoot 'build\Test-R349-PrivilegedLeaf-Actor.ps1' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$oldText='R349_ORIGINAL_BYTES_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$newText='R349_PRIVILEGED_WRITE_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$lease=$null;$child=$null;$priorDelete=$null;$secondLease=$null +$created=$false;$cleanupOk=$false;$failure='';$actorPid=0 +$checks=[Collections.Generic.List[string]]::new() +$beforeHash='';$afterHash='';$actorHash='';$canaryHash='' +$denied=$false;$preopenDeleteRefused=$false;$postReleaseWriteAllowed=$false +function Hash([string]$file){(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Cause([Exception]$error){ + while($error.InnerException -and + ($error -is [Management.Automation.MethodInvocationException] -or + $error -is [Reflection.TargetInvocationException])){$error=$error.InnerException} + return $error +} +function Sharing([Exception]$error){ + $e=Cause $error + # The unchanged production native guard deliberately throws a fresh + # IOException carrying GetLastError=32 in its exact message; its HResult + # is the generic managed IOException code, not the source Win32 HResult. + # Restrict acceptance to this precise native sharing violation only. + return ($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException]) -and + (($e.HResult -in @(-2147024864,-2147024891)) -or + $e.Message -ceq 'SENSOR_REPLAY_LEAF_EXCLUSIVE_OPEN_FAILED_WIN32_32') +} +try{ + if([IO.Directory]::Exists($protected) -or [IO.Directory]::Exists($root) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R349_PREEXISTING_PROGRAMFILES_PRODUCT_OR_FIXTURE_COLLISION' + } + foreach($f in @($source,$setup,$actorFile,$native)){if(![IO.File]::Exists($f)){throw 'R349_SOURCE_PREREQUISITE_MISSING'}} + $asm=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R349_REAL_SETUP_PE_EMBEDDED_HELPER_MISSING'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($source)))){ + throw 'R349_REAL_PE_EMBEDDED_HELPER_AND_SOURCE_DIFFER' + } + $at=$embedded.IndexOf('public sealed class TBMEProtectedBrokerLease',[StringComparison]::Ordinal) + $header=$embedded.LastIndexOf("Add-Type -TypeDefinition @'",$at,[StringComparison]::Ordinal) + $first=$embedded.IndexOf([string][char]10,$header,[StringComparison]::Ordinal) + $last=$embedded.IndexOf(([string][char]10+"'@"),$at,[StringComparison]::Ordinal) + if($at -lt 0 -or $header -lt 0 -or $first -lt 0 -or $last -le $at -or + $last-$first -gt 22000){throw 'R349_BOUND_REAL_CSHARP_EXTRACTION_REFUSED'} + Add-Type -TypeDefinition $embedded.Substring($first+1,$last-$first-1) -ErrorAction Stop + if($null -eq ('TBMEProtectedBrokerLease' -as [type])){throw 'R349_REAL_PRODUCT_LEAF_LEASE_NOT_COMPILED'} + Check 'EXACT_ORIGINAL_SETUP_EMBEDS_NEW_NATIVE_LEAF_LOCK_CLASS' + [void][IO.Directory]::CreateDirectory($root) + [IO.File]::WriteAllText($canary,'R349_OUTSIDE_CANARY',[Text.UTF8Encoding]::new($false)) + $canaryHash=Hash $canary + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid,$rights, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($protected,$acl);$created=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::WriteAllText($leaf,$oldText,[Text.UTF8Encoding]::new($false)) + $beforeHash=Hash $leaf + if((Get-Acl $protected).Owner -notmatch '(?i)Administrators'){throw 'R349_PROTECTED_OWNER_NOT_ADMIN'} + Check 'FRESH_ADMIN_OWNED_PROGRAMFILES_AND_KNOWN_LEAF_SHA_CREATED' + $proof=[ordered]@{ + Schema=1;Revision='R349';SourceCommit=$env:GITHUB_SHA;Guest=$env:COMPUTERNAME + ExpectedLeafPath=$leaf;OriginalSHA256=$beforeHash;Payload=$newText + } + [IO.File]::WriteAllText($authority,($proof|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $lease=[TBMEProtectedBrokerLease]::new($broker) + $streamLeaf=$lease.OpenReplayLeaf($leafName) + if(-not $lease.IsPinned -or $null -eq $streamLeaf){throw 'R349_LEAF_NOT_PINNED'} + $bytes=[byte[]]$lease.ReadReplayLeaf($leafName) + $actual=[Text.Encoding]::UTF8.GetString($bytes) + if($actual -cne $oldText){throw 'R349_ORIGINAL_BYTES_CHANGED_BEFORE_CHILD'} + Check 'EXACT_PROTECTED_LEAF_HANDLE_PINNED_AND_OLD_BYTES_STABLE' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -AuthorityPath "'+$authority+'" -ReceiptPath "'+$actorReceipt+'"' + $psi.CreateNoWindow=$true;$psi.UseShellExecute=$false + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R349_ACTOR_PROCESS_NOT_CREATED'} + $actorPid=$child.Id + if(-not $child.WaitForExit(23000)){throw 'R349_ACTOR_PROCESS_TIMEOUT'} + if($child.ExitCode -ne 0){throw ('R349_ACTOR_PROCESS_NONZERO_'+$child.ExitCode)} + if(-not [IO.File]::Exists($actorReceipt)){throw 'R349_CHILD_TYPED_RECEIPT_NOT_WRITTEN'} + $a=Get-Content -LiteralPath $actorReceipt -Raw|ConvertFrom-Json + $actorHash=Hash $actorReceipt + if($a.Status -cne 'PASS_PRIVILEGED_WRITE_OPEN_REFUSED_BY_PINNED_LEAF' -or + $a.ActorPid -ne $actorPid -or $a.SourceCommit -cne $env:GITHUB_SHA -or + $a.WriteDeniedByRealWindowsSharing -ne $true -or + $a.AlreadyAdmin -ne $true -or + ![string]::IsNullOrWhiteSpace([string]$a.Failure)){ + throw 'R349_SECOND_PRIVILEGED_PROCESS_DID_NOT_PROVE_SHARING_DENIAL' + } + $denied=$true + Check 'DISTINCT_ALREADY_ADMIN_PID_WRITE_OPEN_REJECTED_BY_REAL_NTFS_SHARING' + $bytes=[byte[]]$lease.ReadReplayLeaf($leafName) + if([Text.Encoding]::UTF8.GetString($bytes) -cne $oldText -or + !$lease.IsPinned -or [IO.Directory]::Exists($broker+'_renamed')){ + throw 'R349_PINNED_LEAF_OR_PARENT_CHANGED_DURING_OTHER_ADMIN_WRITE' + } + Check 'REAL_PRODUCTION_LEAF_HANDLE_READBACK_UNCHANGED_DURING_ATTACK' + $lease.Dispose();$lease=$null + # A preopened DELETE-capable handle with broad sharing must block a + # later exclusive leaf lease, even if the directory is pinned. This is + # a fail-closed precondition, not a successful recovery transaction. + Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class R349PreopenedDelete { + [DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] + static extern SafeFileHandle Open(string p,uint a,uint s,IntPtr x,uint c,uint f,IntPtr t); + public static SafeFileHandle Hold(string p) { + SafeFileHandle h=Open(p,0x00010000,7,IntPtr.Zero,3,0x00200000,IntPtr.Zero); + if(h.IsInvalid){int error=Marshal.GetLastWin32Error();h.Dispose();throw new IOException("R349_PREOPEN_DELETE_FAILED_WIN32_"+error);} + return h; + } +} +'@ + $priorDelete=[R349PreopenedDelete]::Hold($leaf) + $secondLease=[TBMEProtectedBrokerLease]::new($broker) + try{ + $null=$secondLease.OpenReplayLeaf($leafName) + throw 'R349_LEAF_LEASE_UNSAFELY_OPENED_WITH_PREEXISTING_DELETE_HANDLE' + }catch{ + if($_.Exception.Message -like 'R349_LEAF_LEASE_UNSAFELY*'){throw} + if(-not (Sharing $_.Exception)){ + throw ('R349_EXPECTED_SHARING_FAIL_FOR_PREOPENED_DELETE_'+(Cause $_.Exception).Message) + } + $preopenDeleteRefused=$true + } + $secondLease.Dispose();$secondLease=$null + $priorDelete.Dispose();$priorDelete=$null + Check 'REAL_PREOPENED_DELETE_ACCESS_HANDLE_BLOCKED_NEW_EXCLUSIVE_LEAF_LOCK' + [IO.File]::WriteAllText($leaf,$newText,[Text.UTF8Encoding]::new($false)) + $afterHash=Hash $leaf + if($afterHash -ceq $beforeHash -or [IO.File]::ReadAllText($leaf) -cne $newText){ + throw 'R349_OWNER_WRITE_AFTER_LEASE_RELEASE_NOT_POSSIBLE' + } + $postReleaseWriteAllowed=$true + Check 'WRITING_KNOWN_TEST_LEAF_ALLOWED_AFTER_EXCLUSIVE_HANDLE_RELEASE' + if((Hash $canary) -cne $canaryHash){throw 'R349_OUTSIDE_CANARY_CHANGED'} + Check 'OUTSIDE_CANARY_UNTOUCHED' +}catch{$failure=$_.Exception.ToString()} +finally{ + if($child){try{if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(6000)}}catch{};$child.Dispose()} + if($lease){$lease.Dispose()} + if($secondLease){$secondLease.Dispose()} + if($priorDelete){$priorDelete.Dispose()} + if($created -and [IO.Directory]::Exists($protected)){ + try{ + if(([IO.File]::GetAttributes($protected) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [IO.Directory]::Exists($broker) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @([IO.Directory]::GetFileSystemEntries($protected)).Count -ne 1 -or + @([IO.Directory]::GetFileSystemEntries($broker)).Count -ne 1 -or + -not [IO.File]::Exists($leaf)){ + throw 'R349_UNEXPECTED_PROTECTED_ROOT_OR_MEMBER_KEEP_FOR_GUEST' + } + $current=[IO.File]::ReadAllText($leaf) + if($current -cne $oldText -and $current -cne $newText){ + throw 'R349_UNEXPECTED_LEAF_BYTES_KEEP_FOR_GUEST' + } + [IO.File]::Delete($leaf) + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($protected,$false) + $cleanupOk=(![IO.Directory]::Exists($protected)) + }catch{$failure+=' R349_PROTECTED_CLEANUP_REFUSED_'+$_.Exception.Message} + } + $status=if(!$failure -and $checks.Count -eq 8 -and $denied -and + $preopenDeleteRefused -and $postReleaseWriteAllowed -and $cleanupOk){ + 'PASS_R349_PRIVILEGED_LEAF_WRITE_AND_PREOPEN_DELETE_DENIED_BY_NATIVE_HANDLES' + }else{'FAIL_R349_NATIVE_LEAF_SHARING_ACCEPTANCE'} + if([IO.Directory]::Exists($root)){ + [IO.File]::WriteAllText($receipt,([ordered]@{ + Schema=1;Revision='R349';Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + RealUnmodifiedSetupEmbeddedCSharp=$true;AlreadyAdminRunner=$true;DistinctOtherAdminPid=$actorPid + OriginalLeafSHA256=$beforeHash;AfterReleaseSHA256=$afterHash + OtherAdminWriteRefused=$denied;PreopenedDeleteHandleRefused=$preopenDeleteRefused + WriteAfterReleaseAllowed=$postReleaseWriteAllowed;ActualNativeLeaseHeld=$true + ChildReceiptSHA256=$actorHash;OutsideCanarySHA256=$canaryHash + CleanupOwnedProgramFilesFixture=$cleanupOk + RealInteractiveUacConsentTested=$false;OriginalFullHelperExecuted=$false + ActualFullJournalReplayWithConcurrentLeafWriterTested=$false + SecurityEntireProductAccepted=$false;OwnerInstallationChanged=$false + Checks=@($checks);Failure=$failure + }|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R349_PINNED_LEAF_RECEIPT='+$receipt) + } + Write-Output ('R349_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count) +} +if($failure){throw ('R349_REPLAY_LEAF_GUARD_NOT_ACCEPTED '+$failure)} diff --git a/build/Test-R354-OriginalDeferredCleanup-Native.ps1 b/build/Test-R354-OriginalDeferredCleanup-Native.ps1 new file mode 100644 index 00000000..2c9eaeea --- /dev/null +++ b/build/Test-R354-OriginalDeferredCleanup-Native.ps1 @@ -0,0 +1,344 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('Nominal','RootJunction','ChildJunction')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if(!$cleanup.WaitForExit(40000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 11 -and $testCleaned -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_REAL_ORIGINAL_SETUP_DEFERRED_CLEANUP_'+$Mode.ToUpperInvariant()+'_SCOPED'}else{'FAIL_R354_NOT_ACCEPTED'} + $receiptData=[ordered]@{ + Schema=1;Revision='R354';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$false + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/build/Test-R355-CompiledDirectoryPin-Native.ps1 b/build/Test-R355-CompiledDirectoryPin-Native.ps1 new file mode 100644 index 00000000..ed8c2f15 --- /dev/null +++ b/build/Test-R355-CompiledDirectoryPin-Native.ps1 @@ -0,0 +1,152 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R355_ONLY_FRESH_DISPOSABLE_GITHUB_WINDOWS' +} +$root=Join-Path $env:RUNNER_TEMP ('R355_DIRECTORY_PIN_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$testDir=Join-Path $root 'owner-test-only' +$moved=Join-Path $root 'owner-moved' +$link=Join-Path $root 'owner-junction' +$receipt=Join-Path $root 'R355_PRODUCTION_PIN_RECEIPT.json' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$checks=[Collections.Generic.List[string]]::new() +$lease=$null;$started=$false;$canary='';$sha='';$deniedCode=-1;$releasedCode=-1 +function Pass([string]$x){$script:checks.Add($x);Write-Output ('PASS '+$x)} +function Hash([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +if([IO.Directory]::Exists($root) -or ![IO.File]::Exists($setup) -or + [IO.Directory]::Exists((Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced'))){ + throw 'R355_GUEST_SCOPE_NOT_FRESH_OR_SETUP_MISSING' +} +[IO.Directory]::CreateDirectory($root)|Out-Null +try{ + $assembly=[Reflection.Assembly]::LoadFrom($setup) + $sourceReader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$sourceReader.ReadToEnd()}finally{$sourceReader.Dispose()} + $text=[IO.File]::ReadAllText((Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs')) + $norm={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $norm $embedded) -cne (& $norm $text)){throw 'R355_ORIGINAL_PE_EMBEDDED_SOURCE_NOT_EXACT'} + Pass 'COMPILED_PRODUCTION_SETUP_PE_EMBEDS_EXACT_NEW_DEFENSIVE_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $build=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $app=$type.GetProperty('AppRoot',$flags) + if(!$build -or !$app){throw 'R355_ORIGINAL_FACTORY_ABSENT'} + $known=[string]$app.GetValue($null,$null) + $proc=[Diagnostics.Process]::GetCurrentProcess() + try{$pidSelf=$proc.Id;$ticks=$proc.StartTime.ToUniversalTime().Ticks}finally{$proc.Dispose()} + $nativeArgs=[object[]]::new(3) + $nativeArgs[0]=([string]$known).PSObject.BaseObject + $nativeArgs[1]=([int]$pidSelf).PSObject.BaseObject + $nativeArgs[2]=([long]$ticks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$build.Invoke($null,$nativeArgs) + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if(!$psi.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or + $psi.Arguments.Length -gt 22080 -or + $psi.UseShellExecute -ne $false -or + ![string]::Equals($psi.FileName, + (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe'), + [StringComparison]::OrdinalIgnoreCase)){ + throw 'R355_PRODUCTION_CLEANUP_COMMAND_CHANGED_INSECURELY' + } + Pass 'REAL_PRODUCTION_DEFERRED_PROCESSINFO_HOST_AND_ENCODEDCOMMAND_BOUNDS' + $code=[Text.Encoding]::Unicode.GetString( + [Convert]::FromBase64String($psi.Arguments.Substring($prefix.Length))) + $tokens=$null;$parseErr=$null + [Management.Automation.Language.Parser]::ParseInput($code,[ref]$tokens,[ref]$parseErr)|Out-Null + if(@($parseErr).Count){throw 'R355_REAL_ENCODEDCOMMAND_POWERSHELL51_PARSE_FAILED'} + $m=[regex]::Matches($code,"(?ms)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@") + if($m.Count -ne 1){throw 'R355_REAL_NATIVE_CLASS_NOT_UNIQUE_IN_PE'} + Add-Type -TypeDefinition $m[0].Groups[1].Value -ErrorAction Stop + Pass 'ACTUAL_COMPILED_DEFERRED_SCRIPT_PS51_AND_NATIVE_CSHARP_COMPILE_PASS' + [IO.Directory]::CreateDirectory($testDir)|Out-Null + $canary=Join-Path $testDir 'R355_TEST_ONLY_CANARY.txt' + [IO.File]::WriteAllText($canary,'R355_TEST_PRIVATE_ONLY',[Text.UTF8Encoding]::new($false)) + $sha=Hash $canary + $lease=[TBMEDeferredDirectoryLeaseSet]::new() + $lease.Pin($testDir) + $lease.Pin($testDir) + $pathBlob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($testDir)) + $moveBlob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($moved)) + $actorCode='$a=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('''+$pathBlob+'''));'+ + '$b=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('''+$moveBlob+'''));'+ + 'try{[IO.Directory]::Move($a,$b);[IO.Directory]::Move($b,$a);exit 61}'+ + 'catch [IO.IOException]{exit 32}catch [UnauthorizedAccessException]{exit 5}' + $actorTokens=$null;$actorErrors=$null + [Management.Automation.Language.Parser]::ParseInput($actorCode,[ref]$actorTokens,[ref]$actorErrors)|Out-Null + if(@($actorErrors).Count){throw 'R355_ACTOR_CODE_GENERATION_PARSE_FAILED'} + $enc=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($actorCode)) + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $actor1=[Diagnostics.Process]::Start($native,('-NoProfile -NonInteractive -EncodedCommand '+$enc)) + if(!$actor1){throw 'R355_SEPARATE_PROCESS_NOT_STARTED'} + try{ + if(!$actor1.WaitForExit(16000)){throw 'R355_SEPARATE_ACTOR_TIMEOUT'} + $deniedCode=$actor1.ExitCode + }finally{$actor1.Dispose()} + if($deniedCode -ne 32 -and $deniedCode -ne 5){throw ('R355_RENAME_NOT_DENIED_WHILE_PINN_'+$deniedCode)} + if([IO.Directory]::Exists($moved) -or (Hash $canary) -cne $sha){ + throw 'R355_ACTOR_CHANGED_PINNED_ROOT_OR_CANARY' + } + Pass 'SEPARATE_NATIVE_WINDOWS_PROCESS_RENAME_REFUSED_DURING_DIRECTORY_LEASE' + $lease.Release($testDir) + $actor2=[Diagnostics.Process]::Start($native,('-NoProfile -NonInteractive -EncodedCommand '+$enc)) + if(!$actor2){throw 'R355_SECOND_ACTOR_NOT_STARTED'} + try{ + if(!$actor2.WaitForExit(16000)){throw 'R355_SECOND_ACTOR_TIMEOUT'} + $releasedCode=$actor2.ExitCode + }finally{$actor2.Dispose()} + if($releasedCode -ne 61 -or ![IO.Directory]::Exists($testDir) -or (Hash $canary) -cne $sha){ + throw ('R355_DIRECTORY_RENAME_NOT_ALLOWED_AFTER_LEASE_RELEASE_'+$releasedCode) + } + Pass 'SAME_ACTOR_RENAME_ALLOWED_AND_RESTORED_AFTER_LEASE_RELEASE' + New-Item -ItemType Junction -Path $link -Target $testDir -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($link) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R355_TEST_JUNCTION_CREATION_NOT_GENUINE' + } + $denied=$false + try{$lease.Pin($link)}catch [IO.IOException]{$denied=$true} + if(!$denied -or (Hash $canary) -cne $sha){throw 'R355_LEASE_FAILOPEN_ON_JUNCTION'} + Pass 'SAME_PRODUCTION_LEASE_CLASS_REFUSED_REAL_TEST_JUNCTION' + $started=$true +}finally{ + if($lease){$lease.Dispose()} + if([IO.Directory]::Exists($link) -and + ([IO.File]::GetAttributes($link) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($link,$false) + } + if([IO.File]::Exists($canary) -and [IO.File]::ReadAllText($canary) -ceq 'R355_TEST_PRIVATE_ONLY'){ + [IO.File]::Delete($canary) + } + if([IO.Directory]::Exists($testDir) -and + @([IO.Directory]::GetFileSystemEntries($testDir)).Count -eq 0){ + [IO.Directory]::Delete($testDir,$false) + } +} +if(!$started -or $checks.Count -ne 6 -or [IO.Directory]::Exists($testDir) -or + [IO.Directory]::Exists($moved) -or [IO.Directory]::Exists($link)){ + throw 'R355_BOUNDED_COMPILED_NATIVE_PROOF_NOT_ACCEPTED' +} +$record=[ordered]@{ + Schema=1;Revision='R355';Status='PASS_SCOPED_ORIGINAL_COMPILED_PE_DIRECTORY_PIN_NATIVE' + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + SetupSHA256=Hash $setup;Tests=@($checks) + ParentPid=$PID;SeparateNativeActorDeniedCode=$deniedCode + SeparateNativeActorAllowedCode=$releasedCode + ExactCompiledProductionNativeClassUsed=$true + ExternalTestCanaryUntouched=$true + OwnerAppOrSensorModified=$false + ActualConcurrentUninstallRaceTested=$false + InteractiveUacTested=$false + WholeSecurityAccepted=$false +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) +'R355_COMPILED_NATIVE_LEASE_PASS_HOST='+$HostLabel+' CHECKS='+$checks.Count diff --git a/build/Test-R355-RealCleanupConcurrentRename-Native.ps1 b/build/Test-R355-RealCleanupConcurrentRename-Native.ps1 new file mode 100644 index 00000000..fc43ef1e --- /dev/null +++ b/build/Test-R355-RealCleanupConcurrentRename-Native.ps1 @@ -0,0 +1,472 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('LiveRace')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$actorProcess=$null;$actorPid=0;$actorReceipt='';$actorReady='';$actorSignal='';$actorProofSha='';$actorDenied=$false;$actorAttempts=0;$actorObservedDeleted=-1;$actorExit=-1;$stageDir='';$raceMoved='';$filesCount=4000 +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'LiveRace'){ + $phase='R355_CREATE_BOUNDED_ONLY_HOSTED_TEST_FILES_FOR_REAL_DELETION' + $stageDir=Join-Path $app 'delete_stage' + [IO.Directory]::CreateDirectory($stageDir)|Out-Null + for($i=0;$i -lt $filesCount;$i++){ + [IO.File]::WriteAllText((Join-Path $stageDir ('{0:d5}.bin' -f $i)), + 'R355_ONLY_TEST_DATA',[Text.UTF8Encoding]::new($false)) + } + $check=@([IO.Directory]::GetFiles($stageDir,'*.bin')) + if($check.Count -ne $filesCount -or + ![IO.File]::Exists((Join-Path $stageDir '00000.bin'))){ + throw 'R355_REAL_CLEANUP_FIXTURE_FILES_INCOMPLETE' + } + Check 'BOUNDED_FOUR_THOUSAND_SYNTHETIC_TEST_FILES_ONLY_IN_HOSTED_APPROOT' + } + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + if($Mode -ceq 'LiveRace'){ + $phase='R355_PRESTART_INDEPENDENT_NATIVE_RENAME_ACTOR' + $actorFile=Join-Path $SourceRoot 'build\Test-R355-RealCleanupRenameActor-Native.ps1' + if(![IO.File]::Exists($actorFile)){throw 'R355_RACE_ACTOR_FILE_MISSING'} + $actorReceipt=Join-Path $receiptRoot 'R355_LIVE_RENAME_ACTOR.json' + $actorReady=Join-Path $receiptRoot 'R355_LIVE_RENAME_READY.txt' + $actorSignal=Join-Path $receiptRoot 'R355_LIVE_RENAME_SIGNAL.json' + $raceMoved=$app+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID + foreach($path in @($actorReceipt,$actorReady,$actorSignal,$raceMoved)){ + if(Test-Path -LiteralPath $path){throw 'R355_PREEXISTING_RACE_CONTROL_OR_MOVED_TARGET'} + } + foreach($path in @($actorFile,$app,$raceMoved,$actorReceipt,$actorReady,$actorSignal)){ + if($path.Contains('"')){throw 'R355_DISALLOWED_QUOTE_IN_TRUSTED_NATIVE_PATH'} + } + $actorPsi=[Diagnostics.ProcessStartInfo]::new() + $actorPsi.FileName=$native + $actorPsi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -HostLabel "'+$HostLabel+'" -AppRoot "'+$app+'" -MovedRoot "'+$raceMoved+'" -ReadyPath "'+$actorReady+'" -SignalPath "'+$actorSignal+'" -ReceiptPath "'+$actorReceipt+'"' + $actorPsi.CreateNoWindow=$true;$actorPsi.UseShellExecute=$false + $actorProcess=[Diagnostics.Process]::Start($actorPsi) + if(!$actorProcess){throw 'R355_ACTOR_PROCESS_FAILED_TO_START'} + $actorPid=$actorProcess.Id + if($actorPid -le 0 -or $actorPid -eq $PID){throw 'R355_ACTOR_NOT_INDEPENDENT_PID'} + $wait=[Diagnostics.Stopwatch]::StartNew() + while(![IO.File]::Exists($actorReady) -and !$actorProcess.HasExited -and $wait.ElapsedMilliseconds -lt 25000){ + Start-Sleep -Milliseconds 5 + } + if(![IO.File]::Exists($actorReady) -or + [IO.File]::ReadAllText($actorReady) -cne ('R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA)){ + throw 'R355_ACTOR_PREHELPER_RENAME_PREFLIGHT_NOT_PROVEN' + } + Check 'SEPARATE_WINDOWS_RENAME_ACTOR_PREHELPER_ORIGINAL_ROOT_MOVE_AND_RESTORE_READY' + } + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if($Mode -ceq 'LiveRace'){ + $signalInfo=[ordered]@{SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;CleanupPid=$cleanupPid} + [IO.File]::WriteAllText($actorSignal,($signalInfo|ConvertTo-Json -Depth 5), + [Text.UTF8Encoding]::new($false)) + } + if(!$cleanup.WaitForExit(70000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + if($Mode -ceq 'LiveRace'){ + $phase='R355_VERIFY_REAL_COMPETITOR_OVERLAPPED_ORIGINAL_CLEANUP_DELETION' + if($null -eq $actorProcess){throw 'R355_ACTOR_LOST_BEFORE_WITNESS'} + if(!$actorProcess.WaitForExit(15000)){ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + throw 'R355_ACTOR_COMPLETION_TIMED_OUT' + } + $actorExit=$actorProcess.ExitCode + if($actorExit -ne 0 -or -not [IO.File]::Exists($actorReceipt)){ + throw ('R355_ACTOR_EXIT_NONZERO_OR_RECEIPT_MISSING_'+$actorExit) + } + $actorEvidence=Get-Content -LiteralPath $actorReceipt -Raw|ConvertFrom-Json + $actorProofSha=Hash $actorReceipt + if($actorEvidence.Status -cne 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' -or + $actorEvidence.SourceCommit -cne $env:GITHUB_SHA -or + $actorEvidence.Host -cne $HostLabel -or + $actorEvidence.ActorPid -ne $actorPid -or + $actorEvidence.OriginalCleanupPid -ne $cleanupPid -or + $actorEvidence.NativeSharingViolationWin32 -ne 32 -or + $actorEvidence.RealRenameDeniedWhileCleanupAlive -ne $true -or + $actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe -lt 0 -or + $actorEvidence.PreHelperRenameSucceededAndRestored -ne $true -or + $actorEvidence.RealRenameAllowedDuringCleanup -ne $false -or + ![string]::IsNullOrEmpty([string]$actorEvidence.Failure)){ + throw 'R355_UNTRUSTED_ACTOR_REPORT_OR_UNPROVEN_LIVE_OVERLAP' + } + $actorDenied=$true + $actorAttempts=[int]$actorEvidence.RenameAttempts + $actorObservedDeleted=[int]$actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe + Check 'ACTUAL_ORIGINAL_COMPILED_CLEANUP_DENIED_CONCURRENT_ROOT_RENAME_AFTER_FIRST_DELETION' + } + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal' -or $Mode -ceq 'LiveRace'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($null -ne $actorProcess){ + try{ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + }catch{$failure+=' R355_ACTOR_CHILD_TERMINATION_FAILED'} + try{$actorProcess.Dispose()}catch{$failure+=' R355_ACTOR_DISPOSE_FAILED'} + $actorProcess=$null + } + if($Mode -ceq 'LiveRace' -and $fixtureCreated){ + # No recursive delete; only files with this exact disposable test name and bytes. + try{ + if([IO.Directory]::Exists($raceMoved) -and -not [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($raceMoved) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($raceMoved,$app) + } + if([IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and + [IO.Directory]::Exists($stageDir) -and + ([IO.File]::GetAttributes($stageDir) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + $members=@([IO.Directory]::GetFileSystemEntries($stageDir)) + if($members.Count -le $filesCount){ + foreach($member in $members){ + $name=[IO.Path]::GetFileName($member) + if($name -cnotmatch '^[0-9]{5}\.bin$' -or + ([IO.File]::GetAttributes($member) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [IO.File]::ReadAllText($member) -cne 'R355_ONLY_TEST_DATA'){ + throw 'R355_UNKNOWN_FIXTURE_MEMBER_CLEANUP_REFUSED' + } + [IO.File]::Delete($member) + } + if(@([IO.Directory]::GetFileSystemEntries($stageDir)).Count -eq 0){ + [IO.Directory]::Delete($stageDir,$false) + } + }else{throw 'R355_TOO_MANY_FIXTURE_MEMBERS_NO_CLEANUP'} + } + }catch{$failure+=' R355_BOUNDED_STAGE_FIXTURE_RECOVERY_FAILED='+$_.Exception.Message} + } + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 14 -and $testCleaned -and + $actorDenied -and $actorProofSha -cne '' -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_R355_ORIGINAL_PE_REAL_CLEANUP_CONCURRENT_ROOT_RENAME_DENIED'}else{'FAIL_R355_LIVE_REAL_CLEANUP_UNPROVEN'} + $receiptData=[ordered]@{ + Schema=1;Revision='R355';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + R355OnlySyntheticOwnedFilesCreated=$filesCount + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$true + SeparateWindowsActorPid=$actorPid;SeparateActorReceiptSHA256=$actorProofSha + RealConcurrentRenameRefusedDuringOriginalCleanup=$actorDenied + RealConcurrentRenameAttempts=$actorAttempts + FirstDeletedOwnedFileWitnessIndex=$actorObservedDeleted + ActorExitCode=$actorExit + NoForeignOrOwnerFilesAltered=$canaryUnchanged + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/build/Test-R355-RealCleanupRenameActor-Native.ps1 b/build/Test-R355-RealCleanupRenameActor-Native.ps1 new file mode 100644 index 00000000..e6c62867 --- /dev/null +++ b/build/Test-R355-RealCleanupRenameActor-Native.ps1 @@ -0,0 +1,151 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$AppRoot, + [Parameter(Mandatory=$true)][string]$MovedRoot, + [Parameter(Mandatory=$true)][string]$ReadyPath, + [Parameter(Mandatory=$true)][string]$SignalPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# A separate real Windows process in an isolated GitHub-hosted runner. +# ONLY renames exact synthetic per-user application root; no junction, +# driver, real installation or user deletion. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R355_REAL_CLEANUP_ACTOR_DISPOSABLE_ONLY' +} +$expected=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$canonical=[IO.Path]::GetFullPath($expected) +$expectedMoved=$canonical+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +if(-not [string]::Equals($canonical,[IO.Path]::GetFullPath($AppRoot),[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($expectedMoved,[IO.Path]::GetFullPath($MovedRoot),[StringComparison]::OrdinalIgnoreCase) -or + @($ReadyPath,$SignalPath,$ReceiptPath|Where-Object{ + -not [IO.Path]::GetFullPath($_).StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) + }).Count -gt 0){ + throw 'R355_ACTOR_UNTRUSTED_PATH_IDENTITY' +} +$actorPid=$PID;$cleanupPid=0;$preflightMoved=$false +$firstMissing=-1;$renameDenied=$false;$renameAllowed=$false +$hresult=0;$attempts=0;$failure='';$phase='PREFLIGHT';$helperAliveAtDenied=$false +$stage=Join-Path $canonical 'delete_stage' +$movedPath=$expectedMoved +try{ + foreach($p in @($ReadyPath,$SignalPath,$ReceiptPath,$movedPath)){ + if([IO.File]::Exists($p) -or [IO.Directory]::Exists($p)){throw 'R355_ACTOR_CONTROL_OR_MOVED_PREEXISTS'} + } + if(-not [IO.Directory]::Exists($canonical) -or + ([IO.File]::GetAttributes($canonical) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R355_ACTOR_FRESH_SYNTHETIC_ROOT_NOT_NORMAL_DIRECTORY' + } + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + throw 'R355_ACTOR_SYNTHETIC_WITNESS_FILE_MISSING' + } + } + # Native same-parent rename must be allowed without Helper's directory lease. + [IO.Directory]::Move($canonical,$movedPath) + if(-not [IO.Directory]::Exists($movedPath)){throw 'R355_PREHELPER_MOVE_NOT_REAL'} + [IO.Directory]::Move($movedPath,$canonical) + if(-not [IO.Directory]::Exists($canonical)){throw 'R355_PREHELPER_MOVE_NOT_RESTORED'} + $preflightMoved=$true + [IO.File]::WriteAllText($ReadyPath,'R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA,[Text.UTF8Encoding]::new($false)) + $phase='WAIT_FOR_NATIVE_ORIGINAL_CLEANUP_PID' + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists($SignalPath) -and $clock.ElapsedMilliseconds -lt 24000){ + Start-Sleep -Milliseconds 2 + } + if(-not [IO.File]::Exists($SignalPath)){throw 'R355_ACTOR_CLEANUP_SIGNAL_TIMEOUT'} + $signal=Get-Content -LiteralPath $SignalPath -Raw|ConvertFrom-Json + if($signal.SourceCommit -cne $env:GITHUB_SHA -or + $signal.Host -cne $HostLabel -or + $signal.CleanupPid -le 0 -or $signal.CleanupPid -eq $PID){ + throw 'R355_ACTOR_CLEANUP_PID_IDENTITY_UNTRUSTED' + } + $cleanupPid=[int]$signal.CleanupPid + $phase='WAIT_FOR_WITNESSED_FIRST_REAL_SYNTHETIC_FILE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 27000){ + $live=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$live){break} + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + $firstMissing=$i;break + } + } + if($firstMissing -ge 0){break} + Start-Sleep -Milliseconds 1 + } + if($firstMissing -lt 0){throw 'R355_ACTOR_NO_REAL_DELETION_WITNESS_DURING_LIVE_CHILD'} + $phase='ATTEMPT_REAL_SAME_PARENT_DIRECTORY_RENAME_DURING_NATIVE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 12000){ + $child=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$child){break} + $attempts++ + try{ + [IO.Directory]::Move($canonical,$movedPath) + $renameAllowed=$true + # Strict test cleanup: only reverse our own exact moved root. + if([IO.Directory]::Exists($movedPath) -and -not [IO.Directory]::Exists($canonical)){ + [IO.Directory]::Move($movedPath,$canonical) + } + break + }catch [IO.IOException]{ + $hresult=$_.Exception.HResult -band 65535 + if($hresult -eq 32 -and $null -ne (Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue)){ + $helperAliveAtDenied=$true;$renameDenied=$true;break + } + }catch [UnauthorizedAccessException]{ + $hresult=$_.Exception.HResult -band 65535 + } + Start-Sleep -Milliseconds 1 + } + if($renameAllowed){throw 'R355_REAL_ORIGINAL_CLEANUP_DIRECTORY_RENAME_ALLOWED_WHILE_CHILD_ACTIVE'} + if(-not $renameDenied -or -not $helperAliveAtDenied){ + throw ('R355_REAL_ORIGINAL_CLEANUP_LEASE_DENIAL_NOT_WITNESSED attempts='+$attempts+' win32='+$hresult) + } +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Never delete synthetic app bytes. If actor's rename got through, restore name + # only if the exact sibling is still present, and fail instead of following links. + try{ + if([IO.Directory]::Exists($movedPath) -and + -not [IO.Directory]::Exists($canonical) -and + ([IO.File]::GetAttributes($movedPath) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($movedPath,$canonical) + } + }catch{$failure+=' ACTOR_RESTORE_FAILED='+$_.Exception.Message} + $status=if(-not $failure -and $renameDenied -and $preflightMoved -and + $helperAliveAtDenied -and $firstMissing -ge 0 -and + $attempts -gt 0 -and $hresult -eq 32){ + 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' + }else{'FAIL_R355_LIVE_CLEANUP_RENAME_NOT_PROVEN'} + $receipt=[ordered]@{ + Schema=1;Revision='R355';Status=$status;SourceCommit=$env:GITHUB_SHA + Host=$HostLabel;ActorPid=$actorPid;OriginalCleanupPid=$cleanupPid + SeparateNativeProcess=$true;PreHelperRenameSucceededAndRestored=$preflightMoved + FirstSyntheticFileAlreadyDeletedBeforeProbe=$firstMissing + RenameAttempts=$attempts;RealRenameAllowedDuringCleanup=$renameAllowed + NativeSharingViolationWin32=$hresult;RealRenameDeniedWhileCleanupAlive=$renameDenied + OriginalCleanupAliveAtDenial=$helperAliveAtDenied + OutsideCanaryTouched=$false;OwnerMachineTouched=$false + InteractiveUACObserved=$false;Failure=$failure + } + try{ + [IO.File]::WriteAllText($ReceiptPath,($receipt|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + }catch{Write-Output ('R355_ACTOR_RECEIPT_WRITE_FAILURE='+$_.Exception.Message)} + Write-Output ('R355_ACTOR_LIVE_RENAME_STATUS='+$status+' tries='+$attempts+' code='+$hresult) +} +if($failure){throw ('R355_ACTOR_FAILED '+$failure)} diff --git a/build/Test-R356-NativePinnedDirectoryDisposition.ps1 b/build/Test-R356-NativePinnedDirectoryDisposition.ps1 new file mode 100644 index 00000000..0c723ccb --- /dev/null +++ b/build/Test-R356-NativePinnedDirectoryDisposition.ps1 @@ -0,0 +1,202 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R356 native feasibility probe only. NO production Setup/Helper mutation. +# Runs on disposable, already-admin GitHub-hosted Windows jobs only. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git -C $PSScriptRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R356_REFUSE_NON_DISPOSABLE_UNPINNED_SOURCE' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R356_ALREADY_ADMIN_GITHUB_WINDOWS_REQUIRED' +} +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$actorScript=Join-Path $PSScriptRoot 'Test-R356-RenameActor-Native.ps1' +if(![IO.File]::Exists($native) -or ![IO.File]::Exists($actorScript)){throw 'R356_REQUIRED_NATIVE_SOURCES_MISSING'} +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP) +$testRoot=Join-Path $temp ('R356_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if([IO.Directory]::Exists($testRoot) -or [IO.File]::Exists($testRoot)){throw 'R356_FIXTURE_PREEXISTS'} +[void][IO.Directory]::CreateDirectory($testRoot) +$target=Join-Path $testRoot 'delete-only-this-directory' +$outside=Join-Path $testRoot 'outside' +$canary=Join-Path $outside 'EXTERNAL_UNTOUCHED.txt' +$leaf=Join-Path $target 'test-leaf.txt' +$receipt=Join-Path $testRoot 'R356_DIRECTORY_DISPOSITION_NATIVE.json' +$checks=[Collections.Generic.List[string]]::new() +$parentPin=$null;$childPin=$null +$exitFailure='';$phase='START' +$preActorPid=0;$inActorPid=0;$nonemptyError=-1;$dispositionCode=-1;$preAllowed=$false;$inDenied=$false;$unlinkDone=$false;$outsideIntact=$false;$cleanupDone=$false +function Check([string]$label){[void]$script:checks.Add($label);Write-Output ('PASS '+$label)} +function Hash-R356File([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} +function Invoke-Actor([string]$which,[string]$path,[string]$externalMarker){ + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($path)) + $e=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($externalMarker)) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorScript+'" -Phase '+$which+' -PathB64 '+$encoded+' -CanaryB64 '+$e + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw ('R356_NATIVE_ACTOR_START_FAILED_'+$which)} + try{ + if(!$proc.WaitForExit(20000)){ + if(!$proc.HasExited){$proc.Kill();[void]$proc.WaitForExit(4000)} + throw ('R356_NATIVE_ACTOR_TIMEOUT_'+$which) + } + $out=$proc.StandardOutput.ReadToEnd() + $err=$proc.StandardError.ReadToEnd() + if($proc.ExitCode -ne 0){throw ('R356_NATIVE_ACTOR_FAILED_'+$which+'_'+$err+'_'+$out)} + $json=$out|ConvertFrom-Json + if($json.Revision -cne 'R356' -or $json.SourceCommit -cne $env:GITHUB_SHA -or + $json.Phase -cne $which -or $json.Pid -le 0 -or $json.CanaryUnchanged -ne $true){ + throw ('R356_NATIVE_ACTOR_RECEIPT_INVALID_'+$which) + } + return $json + }finally{$proc.Dispose()} +} +try{ + $phase='CREATE_SYNTHETIC_TEMP_ONLY' + [void][IO.Directory]::CreateDirectory($target) + [void][IO.Directory]::CreateDirectory($outside) + [IO.File]::WriteAllText($canary,'R356_OUTSIDE_CANARY_NEVER_CHANGE',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash-R356File $canary + [IO.File]::WriteAllText($leaf,'R356_TEMP_LEAF_ONLY',[Text.UTF8Encoding]::new($false)) + Check 'ONLY_FRESH_HOSTED_RUNNER_TEMP_SYNTHETIC_FILES' + $phase='COMPILE_NATIVE_DISPOSITION_CLASS' + Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Text; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public sealed class R356NativeDirectoryLease : IDisposable { + const uint LIST=1,DELETE=0x00010000,SHARE_READ_WRITE=3,EXISTING=3, BACKUP=0x02000000,REPARSE=0x00200000; + const uint DIRECTORY=0x10,TAG_REPARSE=0x400; + [StructLayout(LayoutKind.Sequential)] private struct ATTRTAG {public uint Attributes; public uint ReparseTag;} + [StructLayout(LayoutKind.Sequential)] private struct DISPOSITION {public byte DeleteFile;} + [DllImport("kernel32.dll",EntryPoint="CreateFileW",CharSet=CharSet.Unicode,SetLastError=true)] + static extern SafeFileHandle Open(string path,uint access,uint sharing,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll",EntryPoint="GetFileInformationByHandleEx",SetLastError=true)] + static extern bool Info(SafeFileHandle handle,int cls,out ATTRTAG tag,uint len); + [DllImport("kernel32.dll",EntryPoint="GetFinalPathNameByHandleW",CharSet=CharSet.Unicode,SetLastError=true)] + static extern uint Final(SafeFileHandle handle,StringBuilder text,uint chars,uint flags); + [DllImport("kernel32.dll",EntryPoint="SetFileInformationByHandle",SetLastError=true)] + static extern bool Set(SafeFileHandle handle,int cls,ref DISPOSITION info,uint len); + readonly SafeFileHandle handle; + public R356NativeDirectoryLease(string path,bool writableDeletion){ + string exact=Path.GetFullPath(path).TrimEnd('\\'); + SafeFileHandle h=Open(exact,LIST | (writableDeletion?DELETE:0),SHARE_READ_WRITE,IntPtr.Zero,EXISTING,BACKUP|REPARSE,IntPtr.Zero); + if(h.IsInvalid){int code=Marshal.GetLastWin32Error();h.Dispose();throw new IOException("R356_CREATE_WIN32_"+code);} + try{ + ATTRTAG tag; + if(!Info(h,9,out tag,(uint)Marshal.SizeOf(typeof(ATTRTAG))))throw new IOException("R356_INFO_WIN32_"+Marshal.GetLastWin32Error()); + if((tag.Attributes&DIRECTORY)==0 || (tag.Attributes&TAG_REPARSE)!=0)throw new IOException("R356_NON_DIRECTORY_OR_REPARSE"); + StringBuilder full=new StringBuilder(4096); + uint n=Final(h,full,(uint)full.Capacity,0); + if(n==0 || n>=full.Capacity)throw new IOException("R356_FINAL_WIN32_"+Marshal.GetLastWin32Error()); + string resolved=full.ToString(); + if(resolved.StartsWith(@"\\?\",StringComparison.Ordinal))resolved=resolved.Substring(4); + if(!String.Equals(resolved.TrimEnd('\\'),exact,StringComparison.OrdinalIgnoreCase)) + throw new IOException("R356_REDIRECTED_DIR_IDENTITY"); + handle=h; + }catch{h.Dispose();throw;} + } + public int MarkDeleteByExactHandle(){ + DISPOSITION flags=new DISPOSITION();flags.DeleteFile=1; + if(Set(handle,4,ref flags,(uint)Marshal.SizeOf(typeof(DISPOSITION))))return 0; + return Marshal.GetLastWin32Error(); + } + public void Dispose(){handle.Dispose();} +} +'@ + Check 'MICROSOFT_SUPPORTED_HANDLE_DISPOSITION_NATIVE_CSHARP_COMPILES' + $phase='ACTOR_BEFORE_PIN' + $pre=Invoke-Actor 'Before' $target $canary + if($pre.RenameAllowed -ne $true -or $pre.Win32Error -ne 0){throw 'R356_PRE_PIN_RENAME_WAS_NOT_ALLOWED_AND_RESTORED'} + $preActorPid=[int]$pre.Pid;$preAllowed=$true + Check 'INDEPENDENT_ACTOR_CAN_RENAME_EXACT_DIRECTORY_BEFORE_PIN' + $phase='OPEN_NO_SHARE_DELETE_PARENT_AND_CHILD' + $parentPin=[R356NativeDirectoryLease]::new($testRoot,$false) + $childPin=[R356NativeDirectoryLease]::new($target,$true) + Check 'NATIVE_LIST_AND_DELETE_ACCESS_HELD_WITHOUT_FILE_SHARE_DELETE' + $phase='ACTOR_DURING_PIN' + $during=Invoke-Actor 'During' $target $canary + $inActorPid=[int]$during.Pid + if($during.RenameAllowed -ne $false -or $during.Win32Error -ne 32 -or + $inActorPid -eq $PID -or $inActorPid -eq $preActorPid){throw 'R356_SECOND_ACTOR_RENAME_NOT_DENIED_WIN32_32'} + $inDenied=$true + Check 'INDEPENDENT_ACTOR_RENAME_DENIED_WHILE_EXACT_DELETE_ACCESS_HANDLE_PINNED' + $phase='REFUSE_NONEMPTY_HANDLE_DELETE' + $nonemptyError=$childPin.MarkDeleteByExactHandle() + if($nonemptyError -ne 145){throw ('R356_NONEMPTY_DIRECTORY_UNEXPECTED_DELETE_'+$nonemptyError)} + if(![IO.File]::Exists($leaf)){throw 'R356_UNEXPECTED_NONEMPTY_FILE_LOSS'} + Check 'HANDLE_DELETE_REJECTED_NONEMPTY_DIRECTORY_WIN32_145' + $phase='DELETE_ONLY_SYNTHETIC_LEAF_WITH_HANDLE_STILL_PINNED' + if((Hash-R356File $leaf) -cne ([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create()).ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R356_TEMP_LEAF_ONLY')))).Replace('-','')){ + throw 'R356_SYNTHETIC_LEAF_CHANGED' + } + [IO.File]::Delete($leaf) + if([IO.File]::Exists($leaf) -or @([IO.Directory]::GetFileSystemEntries($target)).Count -ne 0){ + throw 'R356_TARGET_NOT_VERIFIED_EMPTY' + } + Check 'KNOWN_LEAF_REMOVED_AND_PINNED_DIRECTORY_VERIFIED_EMPTY' + $phase='SET_DISPOSITION_INFO_ON_STILL_OPEN_EXACT_HANDLE' + $dispositionCode=$childPin.MarkDeleteByExactHandle() + if($dispositionCode -ne 0){throw ('R356_NATIVE_DISPOSITION_FAILED_WIN32_'+$dispositionCode)} + $childPin.Dispose();$childPin=$null + for($i=0;$i -lt 30 -and [IO.Directory]::Exists($target);$i++){Start-Sleep -Milliseconds 50} + if([IO.Directory]::Exists($target)){throw 'R356_HANDLE_DELETE_DID_NOT_REMOVE_EMPTY_DIRECTORY'} + $unlinkDone=$true + Check 'SET_FILE_INFORMATION_BY_HANDLE_DELETES_ONLY_PINNED_EMPTY_DIRECTORY' + $phase='OUTSIDE_CANARY_AND_PARENT_CLEANUP' + if((Hash-R356File $canary) -cne $outsideSha){throw 'R356_EXTERNAL_CANARY_CHANGED'} + $outsideIntact=$true + Check 'OUTSIDE_CANARY_SHA_UNMODIFIED_DURING_NATIVE_DELETE' + $parentPin.Dispose();$parentPin=$null + if(@([IO.Directory]::GetFileSystemEntries($outside)).Count -ne 1 -or (Hash-R356File $canary) -cne $outsideSha){ + throw 'R356_OUTSIDE_CANARY_MEMBERS_CHANGED' + } + [IO.File]::Delete($canary) + [IO.Directory]::Delete($outside,$false) + if(@([IO.Directory]::GetFileSystemEntries($testRoot)).Count -ne 0){throw 'R356_UNEXPECTED_TEST_FIXTURE_RESIDUE'} + $cleanupDone=$true + Check 'ALL_TEST_OWNED_FILES_REMOVED_WITHOUT_RECURSIVE_DELETE' +}catch{ + $exitFailure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($null -ne $childPin){try{$childPin.Dispose()}catch{$exitFailure+=' CHILD_HANDLE_DISPOSE_FAILED'}} + if($null -ne $parentPin){try{$parentPin.Dispose()}catch{$exitFailure+=' PARENT_HANDLE_DISPOSE_FAILED'}} + $ok=(!$exitFailure -and $preAllowed -and $inDenied -and $nonemptyError -eq 145 -and + $dispositionCode -eq 0 -and $unlinkDone -and $outsideIntact -and $cleanupDone -and $checks.Count -eq 10) + $record=[ordered]@{ + Schema=1;Revision='R356';SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + Status=$(if($ok){'PASS_PINNED_EXACT_HANDLE_DISPOSITION_EMPTY_DIRECTORY_NO_PATH_RACE'}else{'FAIL_R356_PROTOTYPE'}) + OriginalProductImplementationChanged=$false + RealOSNativeSetFileInformationByHandleInvoked=$true + IndependentActorBeforePid=$preActorPid;IndependentActorDuringPid=$inActorPid + BeforePinRenameAllowed=$preAllowed;DuringPinRenameDenied=$inDenied + DuringNativeRenameWin32=$(if($inDenied){32}else{-1}) + NonEmptyDirectoryDeleteWin32=$nonemptyError;EmptyDirectoryDispositionWin32=$dispositionCode + DeletedExactPinnedDirectory=$unlinkDone;OutsideCanaryIntact=$outsideIntact + OwnedTemporaryCleanupComplete=$cleanupDone;NoOwnerMachineTouched=$true + NoUACShown=$true;FullNTFSRaceSecurityProven=$false + Checks=@($checks);Failure=$exitFailure + } + try{[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false))} + catch{Write-Output ('R356_RECEIPT_WRITE_FAILURE '+$_.Exception.Message)} + Write-Output ('R356_NATIVE_PROTOTYPE_STATUS='+$record.Status+' CHECKS='+$checks.Count+' ERR='+$exitFailure) +} +if($exitFailure){throw ('R356_NATIVE_DISPOSITION_PROTOTYPE_FAILED '+$exitFailure)} diff --git a/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 b/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 new file mode 100644 index 00000000..3e52ee8f --- /dev/null +++ b/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 @@ -0,0 +1,475 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('LiveRace')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$actorProcess=$null;$actorPid=0;$actorReceipt='';$actorReady='';$actorSignal='';$actorProofSha='';$actorDenied=$false;$actorAttempts=0;$actorObservedDeleted=-1;$actorExit=-1;$stageDir='';$raceMoved='';$filesCount=4000 +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('$lease.DeletePinned($dir)') -or + !$decoded.Contains('SetFileInformationByHandle') -or + $decoded.Contains('$lease.Release($dir)') -or + $decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'LiveRace'){ + $phase='R355_CREATE_BOUNDED_ONLY_HOSTED_TEST_FILES_FOR_REAL_DELETION' + $stageDir=Join-Path $app 'delete_stage' + [IO.Directory]::CreateDirectory($stageDir)|Out-Null + for($i=0;$i -lt $filesCount;$i++){ + [IO.File]::WriteAllText((Join-Path $stageDir ('{0:d5}.bin' -f $i)), + 'R355_ONLY_TEST_DATA',[Text.UTF8Encoding]::new($false)) + } + $check=@([IO.Directory]::GetFiles($stageDir,'*.bin')) + if($check.Count -ne $filesCount -or + ![IO.File]::Exists((Join-Path $stageDir '00000.bin'))){ + throw 'R355_REAL_CLEANUP_FIXTURE_FILES_INCOMPLETE' + } + Check 'BOUNDED_FOUR_THOUSAND_SYNTHETIC_TEST_FILES_ONLY_IN_HOSTED_APPROOT' + } + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + if($Mode -ceq 'LiveRace'){ + $phase='R355_PRESTART_INDEPENDENT_NATIVE_RENAME_ACTOR' + $actorFile=Join-Path $SourceRoot 'build\Test-R356-OriginalConcurrentRenameActor-Native.ps1' + if(![IO.File]::Exists($actorFile)){throw 'R355_RACE_ACTOR_FILE_MISSING'} + $actorReceipt=Join-Path $receiptRoot 'R355_LIVE_RENAME_ACTOR.json' + $actorReady=Join-Path $receiptRoot 'R355_LIVE_RENAME_READY.txt' + $actorSignal=Join-Path $receiptRoot 'R355_LIVE_RENAME_SIGNAL.json' + $raceMoved=$app+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID + foreach($path in @($actorReceipt,$actorReady,$actorSignal,$raceMoved)){ + if(Test-Path -LiteralPath $path){throw 'R355_PREEXISTING_RACE_CONTROL_OR_MOVED_TARGET'} + } + foreach($path in @($actorFile,$app,$raceMoved,$actorReceipt,$actorReady,$actorSignal)){ + if($path.Contains('"')){throw 'R355_DISALLOWED_QUOTE_IN_TRUSTED_NATIVE_PATH'} + } + $actorPsi=[Diagnostics.ProcessStartInfo]::new() + $actorPsi.FileName=$native + $actorPsi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -HostLabel "'+$HostLabel+'" -AppRoot "'+$app+'" -MovedRoot "'+$raceMoved+'" -ReadyPath "'+$actorReady+'" -SignalPath "'+$actorSignal+'" -ReceiptPath "'+$actorReceipt+'"' + $actorPsi.CreateNoWindow=$true;$actorPsi.UseShellExecute=$false + $actorProcess=[Diagnostics.Process]::Start($actorPsi) + if(!$actorProcess){throw 'R355_ACTOR_PROCESS_FAILED_TO_START'} + $actorPid=$actorProcess.Id + if($actorPid -le 0 -or $actorPid -eq $PID){throw 'R355_ACTOR_NOT_INDEPENDENT_PID'} + $wait=[Diagnostics.Stopwatch]::StartNew() + while(![IO.File]::Exists($actorReady) -and !$actorProcess.HasExited -and $wait.ElapsedMilliseconds -lt 25000){ + Start-Sleep -Milliseconds 5 + } + if(![IO.File]::Exists($actorReady) -or + [IO.File]::ReadAllText($actorReady) -cne ('R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA)){ + throw 'R355_ACTOR_PREHELPER_RENAME_PREFLIGHT_NOT_PROVEN' + } + Check 'SEPARATE_WINDOWS_RENAME_ACTOR_PREHELPER_ORIGINAL_ROOT_MOVE_AND_RESTORE_READY' + } + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if($Mode -ceq 'LiveRace'){ + $signalInfo=[ordered]@{SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;CleanupPid=$cleanupPid} + [IO.File]::WriteAllText($actorSignal,($signalInfo|ConvertTo-Json -Depth 5), + [Text.UTF8Encoding]::new($false)) + } + if(!$cleanup.WaitForExit(70000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + if($Mode -ceq 'LiveRace'){ + $phase='R355_VERIFY_REAL_COMPETITOR_OVERLAPPED_ORIGINAL_CLEANUP_DELETION' + if($null -eq $actorProcess){throw 'R355_ACTOR_LOST_BEFORE_WITNESS'} + if(!$actorProcess.WaitForExit(15000)){ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + throw 'R355_ACTOR_COMPLETION_TIMED_OUT' + } + $actorExit=$actorProcess.ExitCode + if($actorExit -ne 0 -or -not [IO.File]::Exists($actorReceipt)){ + throw ('R355_ACTOR_EXIT_NONZERO_OR_RECEIPT_MISSING_'+$actorExit) + } + $actorEvidence=Get-Content -LiteralPath $actorReceipt -Raw|ConvertFrom-Json + $actorProofSha=Hash $actorReceipt + if($actorEvidence.Status -cne 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' -or + $actorEvidence.SourceCommit -cne $env:GITHUB_SHA -or + $actorEvidence.Host -cne $HostLabel -or + $actorEvidence.ActorPid -ne $actorPid -or + $actorEvidence.OriginalCleanupPid -ne $cleanupPid -or + $actorEvidence.NativeSharingViolationWin32 -ne 32 -or + $actorEvidence.RealRenameDeniedWhileCleanupAlive -ne $true -or + $actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe -lt 0 -or + $actorEvidence.PreHelperRenameSucceededAndRestored -ne $true -or + $actorEvidence.RealRenameAllowedDuringCleanup -ne $false -or + ![string]::IsNullOrEmpty([string]$actorEvidence.Failure)){ + throw 'R355_UNTRUSTED_ACTOR_REPORT_OR_UNPROVEN_LIVE_OVERLAP' + } + $actorDenied=$true + $actorAttempts=[int]$actorEvidence.RenameAttempts + $actorObservedDeleted=[int]$actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe + Check 'ACTUAL_ORIGINAL_COMPILED_CLEANUP_DENIED_CONCURRENT_ROOT_RENAME_AFTER_FIRST_DELETION' + } + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal' -or $Mode -ceq 'LiveRace'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($null -ne $actorProcess){ + try{ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + }catch{$failure+=' R355_ACTOR_CHILD_TERMINATION_FAILED'} + try{$actorProcess.Dispose()}catch{$failure+=' R355_ACTOR_DISPOSE_FAILED'} + $actorProcess=$null + } + if($Mode -ceq 'LiveRace' -and $fixtureCreated){ + # No recursive delete; only files with this exact disposable test name and bytes. + try{ + if([IO.Directory]::Exists($raceMoved) -and -not [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($raceMoved) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($raceMoved,$app) + } + if([IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and + [IO.Directory]::Exists($stageDir) -and + ([IO.File]::GetAttributes($stageDir) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + $members=@([IO.Directory]::GetFileSystemEntries($stageDir)) + if($members.Count -le $filesCount){ + foreach($member in $members){ + $name=[IO.Path]::GetFileName($member) + if($name -cnotmatch '^[0-9]{5}\.bin$' -or + ([IO.File]::GetAttributes($member) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [IO.File]::ReadAllText($member) -cne 'R355_ONLY_TEST_DATA'){ + throw 'R355_UNKNOWN_FIXTURE_MEMBER_CLEANUP_REFUSED' + } + [IO.File]::Delete($member) + } + if(@([IO.Directory]::GetFileSystemEntries($stageDir)).Count -eq 0){ + [IO.Directory]::Delete($stageDir,$false) + } + }else{throw 'R355_TOO_MANY_FIXTURE_MEMBERS_NO_CLEANUP'} + } + }catch{$failure+=' R355_BOUNDED_STAGE_FIXTURE_RECOVERY_FAILED='+$_.Exception.Message} + } + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 14 -and $testCleaned -and + $actorDenied -and $actorProofSha -cne '' -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_R355_ORIGINAL_PE_REAL_CLEANUP_CONCURRENT_ROOT_RENAME_DENIED'}else{'FAIL_R355_LIVE_REAL_CLEANUP_UNPROVEN'} + $receiptData=[ordered]@{ + Schema=1;Revision='R355';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + R355OnlySyntheticOwnedFilesCreated=$filesCount + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$true + SeparateWindowsActorPid=$actorPid;SeparateActorReceiptSHA256=$actorProofSha + RealConcurrentRenameRefusedDuringOriginalCleanup=$actorDenied + RealConcurrentRenameAttempts=$actorAttempts + FirstDeletedOwnedFileWitnessIndex=$actorObservedDeleted + ActorExitCode=$actorExit + NoForeignOrOwnerFilesAltered=$canaryUnchanged + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 b/build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 new file mode 100644 index 00000000..50a167ef --- /dev/null +++ b/build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 @@ -0,0 +1,151 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$AppRoot, + [Parameter(Mandatory=$true)][string]$MovedRoot, + [Parameter(Mandatory=$true)][string]$ReadyPath, + [Parameter(Mandatory=$true)][string]$SignalPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# A separate real Windows process in an isolated GitHub-hosted runner. +# ONLY renames exact synthetic per-user application root; no junction, +# driver, real installation or user deletion. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R355_REAL_CLEANUP_ACTOR_DISPOSABLE_ONLY' +} +$expected=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$canonical=[IO.Path]::GetFullPath($expected) +$expectedMoved=$canonical+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +if(-not [string]::Equals($canonical,[IO.Path]::GetFullPath($AppRoot),[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($expectedMoved,[IO.Path]::GetFullPath($MovedRoot),[StringComparison]::OrdinalIgnoreCase) -or + @($ReadyPath,$SignalPath,$ReceiptPath|Where-Object{ + -not [IO.Path]::GetFullPath($_).StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) + }).Count -gt 0){ + throw 'R355_ACTOR_UNTRUSTED_PATH_IDENTITY' +} +$actorPid=$PID;$cleanupPid=0;$preflightMoved=$false +$firstMissing=-1;$renameDenied=$false;$renameAllowed=$false +$hresult=0;$attempts=0;$failure='';$phase='PREFLIGHT';$helperAliveAtDenied=$false +$stage=Join-Path $canonical 'delete_stage' +$movedPath=$expectedMoved +try{ + foreach($p in @($ReadyPath,$SignalPath,$ReceiptPath,$movedPath)){ + if([IO.File]::Exists($p) -or [IO.Directory]::Exists($p)){throw 'R355_ACTOR_CONTROL_OR_MOVED_PREEXISTS'} + } + if(-not [IO.Directory]::Exists($canonical) -or + ([IO.File]::GetAttributes($canonical) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R355_ACTOR_FRESH_SYNTHETIC_ROOT_NOT_NORMAL_DIRECTORY' + } + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + throw 'R355_ACTOR_SYNTHETIC_WITNESS_FILE_MISSING' + } + } + # Native same-parent rename must be allowed without Helper's directory lease. + [IO.Directory]::Move($canonical,$movedPath) + if(-not [IO.Directory]::Exists($movedPath)){throw 'R355_PREHELPER_MOVE_NOT_REAL'} + [IO.Directory]::Move($movedPath,$canonical) + if(-not [IO.Directory]::Exists($canonical)){throw 'R355_PREHELPER_MOVE_NOT_RESTORED'} + $preflightMoved=$true + [IO.File]::WriteAllText($ReadyPath,'R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA,[Text.UTF8Encoding]::new($false)) + $phase='WAIT_FOR_NATIVE_ORIGINAL_CLEANUP_PID' + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists($SignalPath) -and $clock.ElapsedMilliseconds -lt 24000){ + Start-Sleep -Milliseconds 2 + } + if(-not [IO.File]::Exists($SignalPath)){throw 'R355_ACTOR_CLEANUP_SIGNAL_TIMEOUT'} + $signal=Get-Content -LiteralPath $SignalPath -Raw|ConvertFrom-Json + if($signal.SourceCommit -cne $env:GITHUB_SHA -or + $signal.Host -cne $HostLabel -or + $signal.CleanupPid -le 0 -or $signal.CleanupPid -eq $PID){ + throw 'R355_ACTOR_CLEANUP_PID_IDENTITY_UNTRUSTED' + } + $cleanupPid=[int]$signal.CleanupPid + $phase='WAIT_FOR_WITNESSED_FIRST_REAL_SYNTHETIC_FILE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 27000){ + $live=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$live){break} + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + $firstMissing=$i;break + } + } + if($firstMissing -ge 0){break} + Start-Sleep -Milliseconds 1 + } + if($firstMissing -lt 0){throw 'R355_ACTOR_NO_REAL_DELETION_WITNESS_DURING_LIVE_CHILD'} + $phase='ATTEMPT_REAL_SAME_PARENT_DIRECTORY_RENAME_DURING_NATIVE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 12000){ + $child=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$child){break} + $attempts++ + try{ + [IO.Directory]::Move($canonical,$movedPath) + $renameAllowed=$true + # Strict test cleanup: only reverse our own exact moved root. + if([IO.Directory]::Exists($movedPath) -and -not [IO.Directory]::Exists($canonical)){ + [IO.Directory]::Move($movedPath,$canonical) + } + break + }catch [IO.IOException]{ + $hresult=$_.Exception.HResult -band 65535 + if($hresult -eq 32 -and $null -ne (Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue)){ + $helperAliveAtDenied=$true;$renameDenied=$true;break + } + }catch [UnauthorizedAccessException]{ + $hresult=$_.Exception.HResult -band 65535 + } + Start-Sleep -Milliseconds 1 + } + if($renameAllowed){throw 'R355_REAL_ORIGINAL_CLEANUP_DIRECTORY_RENAME_ALLOWED_WHILE_CHILD_ACTIVE'} + if(-not $renameDenied -or -not $helperAliveAtDenied){ + throw ('R355_REAL_ORIGINAL_CLEANUP_LEASE_DENIAL_NOT_WITNESSED attempts='+$attempts+' win32='+$hresult) + } +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Never delete synthetic app bytes. If actor's rename got through, restore name + # only if the exact sibling is still present, and fail instead of following links. + try{ + if([IO.Directory]::Exists($movedPath) -and + -not [IO.Directory]::Exists($canonical) -and + ([IO.File]::GetAttributes($movedPath) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($movedPath,$canonical) + } + }catch{$failure+=' ACTOR_RESTORE_FAILED='+$_.Exception.Message} + $status=if(-not $failure -and $renameDenied -and $preflightMoved -and + $helperAliveAtDenied -and $firstMissing -ge 0 -and + $attempts -gt 0 -and $hresult -eq 32){ + 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' + }else{'FAIL_R355_LIVE_CLEANUP_RENAME_NOT_PROVEN'} + $receipt=[ordered]@{ + Schema=1;Revision='R355';Status=$status;SourceCommit=$env:GITHUB_SHA + Host=$HostLabel;ActorPid=$actorPid;OriginalCleanupPid=$cleanupPid + SeparateNativeProcess=$true;PreHelperRenameSucceededAndRestored=$preflightMoved + FirstSyntheticFileAlreadyDeletedBeforeProbe=$firstMissing + RenameAttempts=$attempts;RealRenameAllowedDuringCleanup=$renameAllowed + NativeSharingViolationWin32=$hresult;RealRenameDeniedWhileCleanupAlive=$renameDenied + OriginalCleanupAliveAtDenial=$helperAliveAtDenied + OutsideCanaryTouched=$false;OwnerMachineTouched=$false + InteractiveUACObserved=$false;Failure=$failure + } + try{ + [IO.File]::WriteAllText($ReceiptPath,($receipt|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + }catch{Write-Output ('R355_ACTOR_RECEIPT_WRITE_FAILURE='+$_.Exception.Message)} + Write-Output ('R355_ACTOR_LIVE_RENAME_STATUS='+$status+' tries='+$attempts+' code='+$hresult) +} +if($failure){throw ('R355_ACTOR_FAILED '+$failure)} diff --git a/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 b/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 new file mode 100644 index 00000000..4e22a9bc --- /dev/null +++ b/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 @@ -0,0 +1,347 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('Nominal','RootJunction','ChildJunction')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('$lease.DeletePinned($dir)') -or + !$decoded.Contains('SetFileInformationByHandle') -or + $decoded.Contains('$lease.Release($dir)') -or + $decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if(!$cleanup.WaitForExit(40000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 11 -and $testCleaned -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_REAL_ORIGINAL_SETUP_DEFERRED_CLEANUP_'+$Mode.ToUpperInvariant()+'_SCOPED'}else{'FAIL_R354_NOT_ACCEPTED'} + $receiptData=[ordered]@{ + Schema=1;Revision='R354';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$false + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/build/Test-R356-RenameActor-Native.ps1 b/build/Test-R356-RenameActor-Native.ps1 new file mode 100644 index 00000000..0104d7ed --- /dev/null +++ b/build/Test-R356-RenameActor-Native.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('Before','During')][string]$Phase, + [Parameter(Mandatory=$true)][string]$PathB64, + [Parameter(Mandatory=$true)][string]$CanaryB64 +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$'){ + throw 'R356_RENAME_ACTOR_GUEST_ONLY' +} +$path=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($PathB64)) +$canary=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($CanaryB64)) +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +foreach($p in @($path,$canary)){ + if(![IO.Path]::GetFullPath($p).StartsWith($temp,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R356_RENAME_ACTOR_PATH_ESCAPED_RUNNER_TEMP' + } +} +if($path -cnotmatch '\\R356_[AB]_[0-9]{5,20}\\delete-only-this-directory$' -or + -not [IO.Directory]::Exists($path) -or -not [IO.File]::Exists($canary)){ + throw 'R356_RENAME_ACTOR_FIXED_SYNTHETIC_FIXTURE_MISSING' +} +$sha=(Get-FileHash -LiteralPath $canary -Algorithm SHA256).Hash +$renamed=$path+'_moved' +if([IO.Directory]::Exists($renamed) -or [IO.File]::Exists($renamed)){ + throw 'R356_RENAME_ACTOR_DESTINATION_ALREADY_OCCUPIED' +} +$allowed=$false;$win32=-1 +try{ + [IO.Directory]::Move($path,$renamed) + [IO.Directory]::Move($renamed,$path) + $allowed=$true;$win32=0 +}catch [IO.IOException]{ + $win32=$_.Exception.HResult -band 65535 + if([IO.Directory]::Exists($renamed) -and -not [IO.Directory]::Exists($path)){ + try{[IO.Directory]::Move($renamed,$path)}catch{throw 'R356_ACTOR_RESTORE_OF_RENAMED_DIRECTORY_FAILED'} + } +}catch [UnauthorizedAccessException]{ + $win32=$_.Exception.HResult -band 65535 +} +if((Get-FileHash -LiteralPath $canary -Algorithm SHA256).Hash -cne $sha){ + throw 'R356_ACTOR_OUTSIDE_CANARY_CHANGED' +} +if(-not [IO.Directory]::Exists($path)){ + throw 'R356_ACTOR_ORIGINAL_PATH_NOT_PRESENT_AFTER_PROBE' +} +if($Phase -ceq 'Before' -and (-not $allowed -or $win32 -ne 0)){ + throw ('R356_ACTOR_BEFORE_MUST_SUCCEED_WIN32_'+$win32) +} +if($Phase -ceq 'During' -and ($allowed -or $win32 -ne 32)){ + throw ('R356_ACTOR_DURING_MUST_DENY_NATIVE_SHARE32_GOT_'+$win32) +} +[ordered]@{ + Revision='R356';SourceCommit=$env:GITHUB_SHA;Phase=$Phase + Pid=$PID;RenameAllowed=$allowed;Win32Error=$win32;CanaryUnchanged=$true +}|ConvertTo-Json -Compress diff --git a/build/Test-Release-Gates.ps1 b/build/Test-Release-Gates.ps1 index 0f771072..2ea4b52c 100644 --- a/build/Test-Release-Gates.ps1 +++ b/build/Test-Release-Gates.ps1 @@ -44,7 +44,7 @@ if($null -ne $manifest) { 'TaskbarMonitorEnhanced.exe.config'='build\_out\App\TaskbarMonitorEnhanced.exe.config'; 'TaskbarMonitorSensorBroker.exe'='build\_out\Broker\TaskbarMonitorSensorBroker.exe'; 'TaskbarMonitorSensorSupervisor.exe'='build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe'; - 'TaskbarMonitorEnhanced_Setup_1.7.2.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' } $realHashesPass=$true foreach($item in $outputs){ @@ -168,7 +168,7 @@ Add-Gate 'G12_START_SEARCH_24_REAL_NATIVE_INK_AND_EXACT_RETURN' $startFloatPass # R75: G02 hashes deterministic UNSIGNED builder outputs. G14 and G15 must use # separate SIGNED release staging, since Authenticode modifies executable SHA256. $signedMainExe=Join-Path $root 'artifacts\signed\Main\TaskbarMonitorEnhanced.exe' -$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $expectedSignedMainDigest='' if((Test-Path -LiteralPath $signedMainExe -PathType Leaf) -and (Test-Path -LiteralPath $signedSetupExe -PathType Leaf)){ @@ -233,7 +233,7 @@ Add-Gate 'G14_INSTALLED_TASKBAR_EXPLORER_SENSORS_ROLLBACK' $installedPass $insta $setupExe=$signedSetupExe $mainExe=$signedMainExe # Keep original _out builder outputs immutable so G02 exact source checks remain reproducible. -$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' # R73: G15 must prove actual SignPath Foundation publisher, valid timestamps, matched # exact file/product versions AND live provider-origin/payload evidence. Never trust # self-declared "approved" JSON, a test certificate or token-less synthetic output. @@ -253,10 +253,10 @@ if((Test-Path -LiteralPath $setupExe -PathType Leaf) -and (Test-Path -LiteralPat $sameCert=$null -ne $setupCert -and $null -ne $mainCert -and $setupCert.Thumbprint -ceq $mainCert.Thumbprint $exactMetadata=$setupInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and $mainInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and - $setupInfo.ProductVersion -ceq '1.7.2+r126' -and - $mainInfo.ProductVersion -ceq '1.7.2+r126' -and - $setupInfo.FileVersion -ceq '1.7.2.0' -and - $mainInfo.FileVersion -ceq '1.7.2.0' + $setupInfo.ProductVersion -ceq '1.7.8+r286' -and + $mainInfo.ProductVersion -ceq '1.7.8+r286' -and + $setupInfo.FileVersion -ceq '1.7.8.0' -and + $mainInfo.FileVersion -ceq '1.7.8.0' $cryptoPass=([string]$mainSig.Status -ceq 'Valid') -and ([string]$setupSig.Status -ceq 'Valid') -and $mainName -ceq 'SignPath Foundation' -and diff --git a/build/Test-SettingsUi-R244.ps1 b/build/Test-SettingsUi-R244.ps1 new file mode 100644 index 00000000..652eeca7 --- /dev/null +++ b/build/Test-SettingsUi-R244.ps1 @@ -0,0 +1,87 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$MainPath, + [Parameter(Mandatory=$true)][string]$OutDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Inspect the exact candidate WinForms SettingsForm in Windows PowerShell 5.1. +# Never launch the running product, access user's configuration or send GUI input. +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R244_WINDOWS_POWERSHELL_51_REQUIRED'} +if(Test-Path -LiteralPath $OutDir){throw 'R244_OUTPUT_PREEXISTS'} +Add-Type -AssemblyName System.Windows.Forms,System.Drawing +$path=[IO.Path]::GetFullPath($MainPath) +$a=[Reflection.Assembly]::LoadFrom($path) +$cfgType=$a.GetType('TaskbarMonitorEnhanced.AppConfig',$true) +$formType=$a.GetType('TaskbarMonitorEnhanced.SettingsForm',$true) +$flags=[Reflection.BindingFlags]'Instance,Public,NonPublic' +$config=[Activator]::CreateInstance($cfgType,$true) +$config.GlassMode=$true +$config.Opacity=0 +$config.OpacityWhenGlassOff=0.1 +$ctor=@($formType.GetConstructors($flags)|Where-Object {$_.GetParameters().Count -eq 2})[0] +$form=$ctor.Invoke([object[]]@($config,$null)) +$results=[Collections.Generic.List[object]]::new() +$severe=[Collections.Generic.List[string]]::new() +try{ + $toggle=$formType.GetField('glassModeToggle',$flags).GetValue($form) + $stateField=$formType.GetField('glassModeState',$flags) + $state=if($stateField){$stateField.GetValue($form)}else{$null} + $tabs=$formType.GetField('tabsControl',$flags).GetValue($form) + if($null -eq $toggle -or $null -eq $tabs){throw 'R244_MISSING_SETTINGS_GLASS_OR_TABS'} + if($tabs.TabPages.Count -ne 9){$severe.Add(('TAB_COUNT_'+$tabs.TabPages.Count))} + if($toggle.BackColor.A -ne 255){$severe.Add('GLASS_TOGGLE_TRANSPARENT_BACKGROUND')} + if($toggle.Text -ne ''){$severe.Add('GLASS_SWITCH_NATIVE_TEXT_NOT_EMPTY')} + if($toggle.Width -gt 80){$severe.Add('GLASS_CONTROL_TOO_WIDE_WITH_EMBEDDED_STATUS')} + if($null -eq $state){$severe.Add('GLASS_STATE_SEPARATE_LABEL_MISSING')} + else{ + if($state.Parent -ne $toggle.Parent){$severe.Add('GLASS_LABEL_NOT_IN_SAME_PARENT')} + if($state.BackColor.A -ne 255){$severe.Add('GLASS_STATE_LABEL_BACKGROUND_NOT_OPAQUE')} + if($state.Text -cne 'On'){$severe.Add('GLASS_STATUS_NOT_ON')} + if([Drawing.Rectangle]::Intersect($state.Bounds,$toggle.Bounds).Width -gt 0){$severe.Add('GLASS_TRACK_STATUS_OVERLAP')} + $toggle.Checked=$false + if($state.Text -cne 'Off'){$severe.Add('GLASS_STATUS_NOT_OFF_AFTER_TOGGLE')} + $toggle.Checked=$true + if($state.Text -cne 'On'){$severe.Add('GLASS_STATUS_NOT_ON_AFTER_TOGGLE')} + } + foreach($page in $tabs.TabPages){ + $controls=@($page.Controls|Where-Object { + $_ -isnot [Windows.Forms.VScrollBar] -and $_ -isnot [Windows.Forms.HScrollBar]}) + $overlapCount=0 + for($i=0;$i -lt $controls.Count;$i++){ + for($j=$i+1;$j -lt $controls.Count;$j++){ + $left=$controls[$i];$right=$controls[$j] + $int=[Drawing.Rectangle]::Intersect($left.Bounds,$right.Bounds) + if($int.Width -ge 4 -and $int.Height -ge 4){ + $overlapCount++ + $severe.Add(('PAGE_'+$page.Text+'_SIBLING_OVERLAP_'+$left.GetType().Name+'_AND_'+$right.GetType().Name+'_XYWH_'+$int.ToString())) + } + } + } + $results.Add([ordered]@{Page=$page.Text;Controls=$controls.Count;SiblingOverlaps=$overlapCount + AutoScrollWidth=$page.AutoScrollMinSize.Width;AutoScrollHeight=$page.AutoScrollMinSize.Height}) + } + $toggleBitmap=New-Object Drawing.Bitmap($toggle.Width,$toggle.Height) + New-Item -ItemType Directory -Path $OutDir -ErrorAction Stop|Out-Null + try{ + $toggle.CreateControl() + $toggle.DrawToBitmap($toggleBitmap,$toggle.ClientRectangle) + $toggleBitmap.Save((Join-Path $OutDir 'R244_GLASS_SWITCH_ON.png'),[Drawing.Imaging.ImageFormat]::Png) + }finally{$toggleBitmap.Dispose()} + $overlapTotal=0;foreach($entry in $results){$overlapTotal+=[int]$entry['SiblingOverlaps']} + $result=[ordered]@{ + Revision='R244';Status=if($severe.Count -eq 0){'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP'}else{'FAIL_UI_OVERLAP_OR_GLASS_SWITCH'} + MainSHA256=(Get-FileHash -LiteralPath $path).Hash + SettingsPages=$tabs.TabPages.Count;CanvasBounds=@($form.Width,$form.Height) + GlassTrackBounds=$toggle.Bounds.ToString() + GlassTrackBackAlpha=$toggle.BackColor.A + GlassStatusSeparate=$null -ne $state + GlassStatusBounds=if($state){$state.Bounds.ToString()}else{'MISSING'} + SiblingOverlapCount=$overlapTotal + Violations=@($severe.ToArray());PageMetrics=@($results.ToArray()) + ReadOnlyCandidateUI=$true;PersonalInstallationTouched=$false + } + [IO.File]::WriteAllText((Join-Path $OutDir 'R244_SETTINGS_UI.json'),($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($result|ConvertTo-Json -Compress -Depth 8) + if($severe.Count){throw ('R244_SETTINGS_UI_FAIL_CLOSED '+($severe.ToArray() -join '; '))} +}finally{$form.Dispose()} diff --git a/build/Test-ShortcutCreation-R324.ps1 b/build/Test-ShortcutCreation-R324.ps1 new file mode 100644 index 00000000..1e6727e8 --- /dev/null +++ b/build/Test-ShortcutCreation-R324.ps1 @@ -0,0 +1,197 @@ +[CmdletBinding()] +param([string]$SetupPath = '', [string]$MainPath = '') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if([string]::IsNullOrWhiteSpace($MainPath)){ + $MainPath=Join-Path $PSScriptRoot '_out\App\TaskbarMonitorEnhanced.exe' +} +$setupExe=(Resolve-Path -LiteralPath $SetupPath).Path +$main=(Resolve-Path -LiteralPath $MainPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setupExe) +$type=$assembly.GetType('SetupProgram',$true) +$create=@($type.GetMethods([Reflection.BindingFlags]'Static,NonPublic')|Where-Object { + $_.Name -eq 'Shortcut' -and $_.GetParameters().Count -eq 4 +})[0] +$check=$type.GetMethod('AssertOwnedOrAbsentShortcut',[Reflection.BindingFlags]'Static,NonPublic') +$preflight=$type.GetMethod('AssertFreshInstallShortcutPreflight',[Reflection.BindingFlags]'Static,NonPublic') +if(!$create -or !$check -or !$preflight){throw 'R324_NATIVE_SOURCE_METHOD_MISSING'} +$root=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r324_link_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($root)|Out-Null +$passed=0 +$total=0 +$shell=New-Object -ComObject WScript.Shell + +function Create-Fixture([string]$file,[string]$target,[string]$shortcutArguments){ + $link=$shell.CreateShortcut($file) + try{ + $link.TargetPath=$target + $link.Arguments=$shortcutArguments + $link.WorkingDirectory=$root + $link.Description='R324 disposable only' + $link.Save() + }finally{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($link)} + if(!(Test-Path -LiteralPath $file -PathType Leaf)){throw 'R324_FIXTURE_NOT_SAVED'} +} +function Make-Call([Reflection.MethodInfo]$method,[object[]]$raw){ + $values=New-Object 'object[]' $raw.Count + for($i=0;$i -lt $raw.Count;$i++){$values[$i]=$raw[$i].PSObject.BaseObject} + return $method.Invoke($null,$values) +} +function Expect-Reject([string]$name,[string]$file,[string]$expectedTarget,[string]$shortcutArguments){ + $script:total++ + $sha=(Get-FileHash $file -Algorithm SHA256).Hash + $reason='' + try{ + Make-Call $create @([string]$file,[string]$expectedTarget,[string]$shortcutArguments,[string]'disposable')|Out-Null + }catch{ + $errorObject=$_.Exception + while($errorObject.InnerException){$errorObject=$errorObject.InnerException} + $reason=$errorObject.Message + } + if(!$reason.Contains('FOREIGN_SHORTCUT_NAME_CONFLICT') -or + (Get-FileHash $file -Algorithm SHA256).Hash -ne $sha){ + throw ('R324_UNOWNED_LINK_OVERWRITTEN_OR_WRONG_REASON_'+$name+':'+$reason) + } + $script:passed++ + Write-Output ('PASS refused_'+$name) +} +function Expect-OwnSave([string]$name,[string]$file,[string]$target,[string]$shortcutArguments){ + $script:total++ + Make-Call $create @([string]$file,[string]$target,[string]$shortcutArguments,[string]'disposable')|Out-Null + if(!(Test-Path $file -PathType Leaf)){throw ('R324_OWNED_LINK_NOT_SAVED_'+$name)} + $link=$shell.CreateShortcut($file) + try{ + $targetOK=[string]::Equals( + [IO.Path]::GetFullPath([string]$link.TargetPath), + [IO.Path]::GetFullPath($target), + [StringComparison]::OrdinalIgnoreCase) + $argumentsOK=[string]::Equals([string]$link.Arguments,[string]$shortcutArguments,[StringComparison]::Ordinal) + }finally{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($link)} + if(!$targetOK -or !$argumentsOK){throw ('R324_OWNED_LINK_CHANGED_IDENTITY_'+$name)} + $script:passed++ + Write-Output ('PASS owned_'+$name) +} +try{ + $foreign=Join-Path $root 'foreign.lnk' + Create-Fixture $foreign "$env:WINDIR\System32\notepad.exe" '' + Expect-Reject 'other_target' $foreign $main '' + + $mismatch=Join-Path $root 'foreign-arguments.lnk' + Create-Fixture $mismatch $main '/uninstall' + Expect-Reject 'other_arguments' $mismatch $main '--startup-recovery' + + $corrupt=Join-Path $root 'corrupt.lnk' + [IO.File]::WriteAllBytes($corrupt,[byte[]]@(3,4,7,9,12,55)) + Expect-Reject 'corrupt_existing_lnk' $corrupt $main '' + + $tooLarge=Join-Path $root 'oversized.lnk' + [IO.File]::WriteAllBytes($tooLarge,(New-Object 'byte[]' (1024*1024+1))) + Expect-Reject 'oversized_existing_lnk' $tooLarge $main '' + + $normal=Join-Path $root 'owned-main.lnk' + Create-Fixture $normal $main '' + Expect-OwnSave 'same_main_no_arguments' $normal $main '' + + $repair=Join-Path $root 'owned-repair.lnk' + Create-Fixture $repair $setupExe '/repair-sensors' + Expect-OwnSave 'same_repair' $repair $setupExe '/repair-sensors' + + $recovery=Join-Path $root 'owned-recovery.lnk' + Create-Fixture $recovery $main '--startup-recovery' + Expect-OwnSave 'same_recovery' $recovery $main '--startup-recovery' + + $fresh=Join-Path $root 'new-link.lnk' + Expect-OwnSave 'brand_new' $fresh $main '' + + if($passed -ne 8 -or $total -ne 8){throw 'R324_NATIVE_OWNERSHIP_COUNT_MISMATCH'} + Write-Output 'R324_SHORTCUT_WRITE_NATIVE_PASS=8/8' +}finally{ + if($null -ne $shell){[void][Runtime.InteropServices.Marshal]::ReleaseComObject($shell)} + if(Test-Path -LiteralPath $root){[IO.Directory]::Delete($root,$true)} +} + +# Real /install negative is isolated to a dedicated fresh hosted Windows job. +# The general build job may have already created product test state. +if($env:TBME_R324_RUN_FRESH_HOST_NEGATIVE -eq 'true'){ + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC') { + throw 'R324_FRESH_INSTALL_NEGATIVE_GITHUB_DISPOSABLE_ONLY' + } + $appRoot=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $programs=[Environment]::GetFolderPath('Programs') + $existing=Join-Path $programs 'Taskbar Monitor Enhanced.lnk' + if((Test-Path $appRoot) -or (Test-Path $existing)){throw 'R324_GUEST_NOT_FRESH'} + $userShell=New-Object -ComObject WScript.Shell + $created=$false + $sentinelHash='' + try{ + $link=$userShell.CreateShortcut($existing) + try{ + $link.TargetPath="$env:WINDIR\System32\notepad.exe" + $link.Arguments='' + $link.Save() + $created=$true + }finally{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($link)} + $sentinelHash=(Get-FileHash $existing -Algorithm SHA256).Hash + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setupExe + $psi.Arguments='/install /quiet /desktop=0 /startup=0' + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw 'R324_ORIGINAL_SETUP_NOT_LAUNCHED'} + try{ + if(!$proc.WaitForExit(30000)){ + if(!$proc.HasExited){$proc.Kill();[void]$proc.WaitForExit(5000)} + throw 'R324_SETUP_PREFLIGHT_TIMEOUT' + } + $code=$proc.ExitCode + }finally{$proc.Dispose()} + if($code -eq 0 -or (Test-Path $appRoot) -or + (Get-FileHash $existing -Algorithm SHA256).Hash -ne $sentinelHash){ + throw ('R324_FOREIGN_GUEST_LINK_CHANGED_OR_APP_INSTALLED_EXIT_'+$code) + } + Write-Output 'R324_REAL_HOSTED_SETUP_FOREIGN_START_LINK_FAILS_BEFORE_APPROOT=PASS' + }finally{ + if($created -and (Test-Path $existing) -and + (Get-FileHash $existing -Algorithm SHA256).Hash -eq $sentinelHash){ + Remove-Item -LiteralPath $existing -Force + } + if($null -ne $userShell){[void][Runtime.InteropServices.Marshal]::ReleaseComObject($userShell)} + } + if((Test-Path -LiteralPath $appRoot) -or (Test-Path -LiteralPath $existing) -or + !$created -or $code -eq 0 -or $env:R324_HOST_LABEL -cnotin @('A','B') -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA)){ + throw 'R324_GUEST_CLEANUP_OR_SOURCE_WITNESS_INVALID' + } + $proofDir=Join-Path $env:RUNNER_TEMP ('R324_'+$env:R324_HOST_LABEL+'_'+$env:GITHUB_RUN_ID) + [IO.Directory]::CreateDirectory($proofDir)|Out-Null + $evidence=[ordered]@{ + Schema=1 + Revision='R324' + Status='PASS_REAL_FRESH_SETUP_REFUSES_FOREIGN_START_SHORTCUT_BEFORE_APPROOT' + Host=$env:R324_HOST_LABEL + SourceCommit=$env:GITHUB_SHA + HostedWindows=$env:RUNNER_OS + RealSetupExecuted=$true + SetupNonzeroExitCode=[int]$code + ExistingStartShortcutSHA256=[string]$sentinelHash + SameStartShortcutSHA256Preserved=$true + UserAppRootAbsentBeforeAndAfter=$true + UnownedLinkRemovedAfterWitness=$true + NoUacHelperStarted=$true + SetupSHA256=(Get-FileHash -LiteralPath $setupExe -Algorithm SHA256).Hash + MainSHA256=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + NativeReflectionChecksPassed=8 + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText((Join-Path $proofDir 'R324_HOSTED_SHORTCUT_RECEIPT.json'), + ($evidence|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + Write-Output 'R324_HOSTED_SHORTCUT_CLEANUP_AND_TYPED_RECEIPT=PASS' +} diff --git a/build/Test-ShortcutOwnership-R323.ps1 b/build/Test-ShortcutOwnership-R323.ps1 new file mode 100644 index 00000000..c854458a --- /dev/null +++ b/build/Test-ShortcutOwnership-R323.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [string]$SetupPath = '', + [string]$MainPath = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if ([string]::IsNullOrWhiteSpace($SetupPath)) { + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if ([string]::IsNullOrWhiteSpace($MainPath)) { + $MainPath=Join-Path $PSScriptRoot '_out\App\TaskbarMonitorEnhanced.exe' +} +if (!(Test-Path -LiteralPath $SetupPath -PathType Leaf) -or + !(Test-Path -LiteralPath $MainPath -PathType Leaf)) { + throw 'R323_EXACT_COMPILED_EXECUTABLES_REQUIRED' +} +$main=[IO.Path]::GetFullPath((Resolve-Path -LiteralPath $MainPath).Path) +$setupExe=[IO.Path]::GetFullPath((Resolve-Path -LiteralPath $SetupPath).Path) +$assembly=[Reflection.Assembly]::LoadFrom($setupExe) +$program=$assembly.GetType('SetupProgram',$true) +$method=$program.GetMethod('DeleteOwnedShortcut',[Reflection.BindingFlags]'NonPublic,Static') +if($null -eq $method -or $method.ReturnType -ne [bool]) { + throw 'R323_OWNED_DELETE_METHOD_MISSING' +} +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r323_shortcuts_'+[Guid]::NewGuid().ToString('N')) +[void][IO.Directory]::CreateDirectory($base) +$canary=Join-Path $base 'external-user-owned-canary.txt' +[IO.File]::WriteAllText($canary,'PRESERVE-FOREIGN-OWNER') +$passed=0 +$total=0 +$checked=[Collections.Generic.List[string]]::new() + +function Make-Shortcut([string]$name,[string]$target,[string]$shortcutArguments) { + $dest=Join-Path $base $name + $shell=New-Object -ComObject WScript.Shell + $shortcut=$null + try { + $shortcut=$shell.CreateShortcut($dest) + $shortcut.TargetPath=$target + $shortcut.Arguments=$shortcutArguments + $shortcut.WorkingDirectory=$base + $shortcut.Description='R323 DISPOSABLE TEST ONLY' + $shortcut.Save() + } finally { + if ($null -ne $shortcut) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($shortcut) } + if ($null -ne $shell) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } + } + if (!(Test-Path -LiteralPath $dest -PathType Leaf)) {throw ('R323_FIXTURE_SHORTCUT_MISSING_'+$name)} + return $dest +} + +function Invoke-Delete([string]$file,[string]$expected,[string]$shortcutArguments) { + $call=New-Object 'object[]' 3 + $call[0]=[string]$file + $call[1]=[string]$expected + $call[2]=[string]$shortcutArguments + return [bool]$method.Invoke($null,$call) +} + +function Assert-Positive([string]$name,[string]$path,[string]$target,[string]$shortcutArguments) { + $script:total++ + if(-not (Invoke-Delete $path $target $shortcutArguments) -or + (Test-Path -LiteralPath $path)) { + throw ('R323_OWNED_SHORTCUT_NOT_REMOVED_'+$name) + } + $script:passed++ + [void]$checked.Add('PASS '+$name) +} + +function Assert-Preserve([string]$name,[string]$path,[string]$target,[string]$shortcutArguments,[bool]$exists) { + $script:total++ + $before=if($exists){(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash}else{'NONE'} + if(Invoke-Delete $path $target $shortcutArguments) { + throw ('R323_FOREIGN_SHORTCUT_WAS_DELETED_'+$name) + } + if($exists){ + if(!(Test-Path -LiteralPath $path -PathType Leaf) -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $before) { + throw ('R323_USER_FILE_CHANGED_'+$name) + } + } elseif(Test-Path -LiteralPath $path){ + throw ('R323_MISSING_SHORTCUT_WAS_CREATED_'+$name) + } + $script:passed++ + [void]$checked.Add('PASS '+$name) +} + +try { + $owned=Make-Shortcut 'owned-main.lnk' $main '' + Assert-Positive 'owned_main_no_arguments' $owned $main '' + + $repair=Make-Shortcut 'owned-repair.lnk' $setupExe '/repair-sensors' + Assert-Positive 'owned_repair_exact_arguments' $repair $setupExe '/repair-sensors' + + $recovery=Make-Shortcut 'owned-recovery.lnk' $main '--startup-recovery' + Assert-Positive 'owned_recovery_exact_arguments' $recovery $main '--startup-recovery' + + $foreign=Make-Shortcut 'foreign-target.lnk' "$env:SystemRoot\System32\notepad.exe" '' + Assert-Preserve 'foreign_target_same_link_name' $foreign $main '' $true + + $wrong=Make-Shortcut 'foreign-argument.lnk' $main '/uninstall' + Assert-Preserve 'foreign_arguments_same_binary' $wrong $main '--startup-recovery' $true + + $corrupt=Join-Path $base 'corrupt.lnk' + [IO.File]::WriteAllBytes($corrupt,[byte[]]@(0,7,41,255,18,99)) + Assert-Preserve 'corrupt_link_refused' $corrupt $main '' $true + + $notShortcut=Join-Path $base 'user-custom.txt' + [IO.File]::WriteAllText($notShortcut,'PRESERVE-USER-TEXT') + Assert-Preserve 'non_lnk_extension_refused' $notShortcut $main '' $true + + $missing=Join-Path $base 'missing.lnk' + Assert-Preserve 'missing_link_is_noop' $missing $main '' $false + + $unsafeArgs=Make-Shortcut 'unsafe-expected-arguments.lnk' $main '-EncodedCommand sensitive' + Assert-Preserve 'unsupported_expected_arguments_refused' $unsafeArgs $main '-EncodedCommand sensitive' $true + + $invalidTarget=Make-Shortcut 'relative-expected-target.lnk' $main '' + Assert-Preserve 'relative_expected_target_refused' $invalidTarget 'relative.exe' '' $true + + $oversize=Join-Path $base 'oversize.lnk' + $buffer=New-Object 'byte[]' (1024*1024+1) + [IO.File]::WriteAllBytes($oversize,$buffer) + Assert-Preserve 'oversize_lnk_refused' $oversize $main '' $true + + if([IO.File]::ReadAllText($canary) -ne 'PRESERVE-FOREIGN-OWNER') { + throw 'R323_EXTERNAL_CANARY_CHANGED' + } + if($passed -ne 11 -or $total -ne 11) { throw 'R323_NATIVE_CHECK_COUNT_MISMATCH' } + $checked | ForEach-Object { Write-Output $_ } + Write-Output ('R323_OWNED_SHORTCUT_NATIVE_PASS='+$passed+'/'+$total) +} finally { + if(Test-Path -LiteralPath $base) { + [IO.Directory]::Delete($base,$true) + } +} diff --git a/build/Test-Signed-Payload-Contract.ps1 b/build/Test-Signed-Payload-Contract.ps1 index a5f1ddcb..607f67b7 100644 --- a/build/Test-Signed-Payload-Contract.ps1 +++ b/build/Test-Signed-Payload-Contract.ps1 @@ -4,11 +4,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $original=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $inputDir=Join-Path $Root 'artifacts\_r80_contract_input' $inputFile=Join-Path $inputDir 'TaskbarMonitorEnhanced.exe' $stageDir=Join-Path $Root 'artifacts\_r80_contract_only' -$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' $originalMainSHA=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash $originalSetupSHA=(Get-FileHash -LiteralPath $setupOrig -Algorithm SHA256).Hash if((Test-Path -LiteralPath $inputDir) -or (Test-Path -LiteralPath $stageDir)){ @@ -70,7 +70,7 @@ try{ # Cleanup exact owned files only. Unknown files survive; never delete personal state. foreach($d in @($stageDir,$inputDir)){ if(Test-Path -LiteralPath $d){ - $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.2.exe','TaskbarMonitorEnhanced_Setup_1.7.2.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} + $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.8.exe','TaskbarMonitorEnhanced_Setup_1.7.8.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} else{@('TaskbarMonitorEnhanced.exe')} $found=@(Get-ChildItem -LiteralPath $d -Force) if(@($found|Where-Object {$_.PSIsContainer -or $_.Name -notin $allowed}).Count -gt 0){ diff --git a/build/Test-Signed-Staging.ps1 b/build/Test-Signed-Staging.ps1 index c051d937..a035bc51 100644 --- a/build/Test-Signed-Staging.ps1 +++ b/build/Test-Signed-Staging.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $stage=Join-Path $root 'artifacts\signed' $origMain=Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $targetMain=Join-Path $stage 'Main\TaskbarMonitorEnhanced.exe' -$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $created=@() if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $origMain -PathType Leaf) -or @@ -26,7 +26,7 @@ if(!(Test-Path -LiteralPath $manifest -PathType Leaf)){throw 'R75 MANIFEST REQUI $m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $mapping=@{ 'TaskbarMonitorEnhanced.exe'=$hashMain - 'TaskbarMonitorEnhanced_Setup_1.7.2.exe'=$hashSetup + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe'=$hashSetup } foreach($entry in $m.Outputs){ if($mapping.ContainsKey([string]$entry.Name)){ diff --git a/build/Test-Upgrade172-R243.ps1 b/build/Test-Upgrade172-R243.ps1 new file mode 100644 index 00000000..3e56d37b --- /dev/null +++ b/build/Test-Upgrade172-R243.ps1 @@ -0,0 +1,218 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R243_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R243_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R243_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.3' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R243_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R243_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R243_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v172-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.3.md','install_state.json','config.json','config.json.bak','R243_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R243_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R243_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.2 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.2.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R243_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' + if((Hash $priorSetup) -cne '225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4'){throw 'R243_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.1 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.1.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R243_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.1.exe' + if((Hash $older) -cne 'ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89'){throw 'R243_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R243_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R243_GENUINE_V171_TO_V172_FAILED'} + $chain=@('1.7.1','1.7.2') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R243_GENUINE_V172_INSTALL_FAILED'} + $chain=@('1.7.2') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.2'){throw 'R243_OFFICIAL_V172_INSTALLED_IDENTITY_NOT_PROVEN'} + $phase='KNOWN_USER_DATA_FIXTURE' + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R243OwnerSentinel":"preserve user literal 1.7.2"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R243_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.2"','"Version": "1.7.3"').Replace('"PublicVersion": "1.7.2"','"PublicVersion": "1.7.3"') + if($expectedState -ceq $sourceText){throw 'R243_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R243_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.2' '84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R243_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R243_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R243_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.2','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R243_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.2','OFFICIAL_STABLE_V172')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.2','OFFICIAL_STABLE_V172')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R243_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R243_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R243_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R243_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.3' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R243_STATE_OR_REGISTRY_NOT_EXACT_172'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R243_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R243_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R243_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.3.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.2.md'))){throw 'R243_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R243_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.3') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.3' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R243_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R243_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_172_TO_173_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R243';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R243_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R243_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-Upgrade173-R256.ps1 b/build/Test-Upgrade173-R256.ps1 new file mode 100644 index 00000000..aa261a59 --- /dev/null +++ b/build/Test-Upgrade173-R256.ps1 @@ -0,0 +1,227 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R256_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R256_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R256_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.4' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R256_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R256_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R256_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.4.md','install_state.json','config.json','config.json.bak','R256_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R256_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R256_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.3 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.3.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R256_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' + if((Hash $priorSetup) -cne '9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B'){throw 'R256_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.1 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.1.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R256_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.1.exe' + if((Hash $older) -cne 'ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89'){throw 'R256_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R256_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R256_GENUINE_V171_TO_V173_FAILED'} + $chain=@('1.7.1','1.7.3') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R256_GENUINE_V173_INSTALL_FAILED'} + $chain=@('1.7.3') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.3'){throw 'R256_OFFICIAL_V173_INSTALLED_IDENTITY_NOT_PROVEN'} + # Distinguish genuine published v1.7.3 fresh versus upgraded old installs. + $extra=Join-Path $app 'TaskbarMonitorEnhanced.cs' + $source=Join-Path $app 'Source/TaskbarMonitorEnhanced.cs' + if($HostLabel -ceq 'A'){ + if((Hash $extra) -cne '5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32' -or + (Hash $source) -cne (Hash $extra)){throw 'R256_OFFICIAL_V173_FRESH_DUPLICATE_NOT_EXACT'} + }else{ + if(Test-Path $extra){throw 'R256_OFFICIAL_V173_UPGRADED_ROOT_SHOULD_BE_ABSENT'} + } + $phase='KNOWN_USER_DATA_FIXTURE' + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R256OwnerSentinel":"preserve user literal 1.7.3"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R256_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.3"','"Version": "1.7.4"').Replace('"PublicVersion": "1.7.3"','"PublicVersion": "1.7.4"') + if($expectedState -ceq $sourceText){throw 'R256_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R256_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.3' '6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R256_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R256_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R256_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.3','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R256_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.3','OFFICIAL_STABLE_V173')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R256_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R256_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R256_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R256_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.4' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R256_STATE_OR_REGISTRY_NOT_EXACT_174'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R256_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R256_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R256_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.4.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.3.md'))){throw 'R256_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R256_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.4') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.4' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R256_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R256_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_173_TO_174_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R256';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R256_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R256_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-Upgrade174-R265.ps1 b/build/Test-Upgrade174-R265.ps1 new file mode 100644 index 00000000..dcf49c75 --- /dev/null +++ b/build/Test-Upgrade174-R265.ps1 @@ -0,0 +1,224 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R265_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R265_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R265_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.5' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R265_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R265_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R265_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v174-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.5.md','install_state.json','config.json','config.json.bak','R265_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R265_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R265_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.4 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.4.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R265_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' + if((Hash $priorSetup) -cne 'C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1'){throw 'R265_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.3 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.3.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R265_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' + if((Hash $older) -cne '9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B'){throw 'R265_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R265_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R265_GENUINE_V171_TO_V173_FAILED'} + $chain=@('1.7.3','1.7.4') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R265_GENUINE_V173_INSTALL_FAILED'} + $chain=@('1.7.4') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.4'){throw 'R265_OFFICIAL_V173_INSTALLED_IDENTITY_NOT_PROVEN'} + # Neither fresh official v1.7.4 nor the v1.7.3→v1.7.4 upgrade includes + # a redundant root C# source; original R254 defect was already fixed. + $extra=Join-Path $app 'TaskbarMonitorEnhanced.cs' + $source=Join-Path $app 'Source/TaskbarMonitorEnhanced.cs' + if((Test-Path $extra) -or (Hash $source) -ceq 'MISSING'){ + throw 'R265_PUBLIC_V174_SOURCE_LAYOUT_NOT_CANONICAL' + } + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R265OwnerSentinel":"preserve user literal 1.7.4"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R265_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.4"','"Version": "1.7.5"').Replace('"PublicVersion": "1.7.4"','"PublicVersion": "1.7.5"') + if($expectedState -ceq $sourceText){throw 'R265_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R265_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.4' '279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R265_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R265_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R265_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.4','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R265_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V174')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.4','OFFICIAL_STABLE_V174')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R265_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R265_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R265_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R265_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.5' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R265_STATE_OR_REGISTRY_NOT_EXACT_174'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R265_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R265_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R265_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.5.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.4.md'))){throw 'R265_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R265_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.5') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.5' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R265_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R265_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_174_TO_175_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R265';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R265_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R265_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-Upgrade175-R273.ps1 b/build/Test-Upgrade175-R273.ps1 new file mode 100644 index 00000000..4f00af0b --- /dev/null +++ b/build/Test-Upgrade175-R273.ps1 @@ -0,0 +1,224 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R273_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R273_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R273_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.6' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R273_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R273_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R273_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v175-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.6.md','install_state.json','config.json','config.json.bak','R273_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R273_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R273_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.5 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.5.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R273_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' + if((Hash $priorSetup) -cne '8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC'){throw 'R273_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.4 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.4.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R273_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' + if((Hash $older) -cne 'C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1'){throw 'R273_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R273_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R273_GENUINE_V171_TO_V173_FAILED'} + $chain=@('1.7.4','1.7.5') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R273_GENUINE_V173_INSTALL_FAILED'} + $chain=@('1.7.5') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.5'){throw 'R273_OFFICIAL_V173_INSTALLED_IDENTITY_NOT_PROVEN'} + # Neither fresh official v1.7.5 nor the v1.7.4→v1.7.5 upgrade includes + # a redundant root C# source; original R254 defect was already fixed. + $extra=Join-Path $app 'TaskbarMonitorEnhanced.cs' + $source=Join-Path $app 'Source/TaskbarMonitorEnhanced.cs' + if((Test-Path $extra) -or (Hash $source) -ceq 'MISSING'){ + throw 'R273_PUBLIC_V174_SOURCE_LAYOUT_NOT_CANONICAL' + } + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R273OwnerSentinel":"preserve user literal 1.7.5"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R273_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.5"','"Version": "1.7.6"').Replace('"PublicVersion": "1.7.5"','"PublicVersion": "1.7.6"') + if($expectedState -ceq $sourceText){throw 'R273_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R273_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.5' '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R273_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R273_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R273_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.5','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R273_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.5','OFFICIAL_STABLE_V175')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.5','OFFICIAL_STABLE_V175')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R273_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R273_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R273_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R273_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.6' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R273_STATE_OR_REGISTRY_NOT_EXACT_174'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R273_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R273_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R273_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.6.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.5.md'))){throw 'R273_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R273_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.6') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.6' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R273_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R273_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_175_TO_176_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R273';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R273_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R273_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-UpgradeContract-R243.ps1 b/build/Test-UpgradeContract-R243.ps1 new file mode 100644 index 00000000..a9bea598 --- /dev/null +++ b/build/Test-UpgradeContract-R243.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R243_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R243_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.3.exe'){throw 'R243_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.3'){throw 'R243_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.3"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.3"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R243_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.4","PublicVersion":"1.7.4"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R243_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v172-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.2','OFFICIAL_STABLE_V172')) +if($dict.Count -ne 23 -or $inventory.ManagedCount -ne 23){throw 'R243_171_EXACT_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R243_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.3','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R243_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R243_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R243_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R243_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 5 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.3.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R243_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R243';Status='PASS_COMPILED_173_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official172ManagedFiles=23;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2');CurrentVersion='1.7.3';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R243_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Test-UpgradeContract-R256.ps1 b/build/Test-UpgradeContract-R256.ps1 new file mode 100644 index 00000000..5423c400 --- /dev/null +++ b/build/Test-UpgradeContract-R256.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R256_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R256_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.4.exe'){throw 'R256_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.4'){throw 'R256_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.4"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.4"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R256_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.5","PublicVersion":"1.7.5"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R256_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) +if($dict.Count -ne 23 -or $inventory.ManagedCount -ne 23){throw 'R256_171_EXACT_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R256_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.4','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R256_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171','OFFICIAL_STABLE_V172','OFFICIAL_STABLE_V173')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R256_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R256_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R256_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 6 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.4.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R256_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R256';Status='PASS_COMPILED_174_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official173ManagedFiles=23;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3');CurrentVersion='1.7.4';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R256_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Test-UpgradeContract-R265.ps1 b/build/Test-UpgradeContract-R265.ps1 new file mode 100644 index 00000000..76d8c068 --- /dev/null +++ b/build/Test-UpgradeContract-R265.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R265_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R265_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.5.exe'){throw 'R265_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.5'){throw 'R265_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.5"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.5"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R265_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.6","PublicVersion":"1.7.6"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R265_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v174-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V174')) +if($dict.Count -ne 22 -or $inventory.ManagedCount -ne 22){throw 'R265_OFFICIAL_174_EXACT_22_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R265_OFFICIAL_V174_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +# Retain exact previously accepted immutable v1.7.3 path as a separate ancestor. +$oldInventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json +$oldDictionary=$legacy.Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) +if($oldInventory.ManagedCount -ne 23 -or $oldDictionary.Count -ne 23){throw 'R265_HISTORICAL_V173_23_ROWS_DRIFT'} +foreach($row in $oldInventory.Rows){if(!$oldDictionary.ContainsKey($row.Path) -or $oldDictionary[$row.Path] -cne $row.SHA256){throw ('R265_V173_HISTORIC_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.5','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R265_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171','OFFICIAL_STABLE_V172','OFFICIAL_STABLE_V173')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R265_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R265_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +if($optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs','OFFICIAL_STABLE_V174'))){ + throw 'R265_OFFICIAL_V174_MUST_NOT_HAVE_OPTIONAL_ROOT_SOURCE' +} +$cases++ +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R265_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 7 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.5.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R265_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R265';Status='PASS_COMPILED_174_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official173ManagedFiles=23;Official174ManagedFiles=22;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4');CurrentVersion='1.7.5';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R265_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Test-UpgradeContract-R273.ps1 b/build/Test-UpgradeContract-R273.ps1 new file mode 100644 index 00000000..a7ee29ec --- /dev/null +++ b/build/Test-UpgradeContract-R273.ps1 @@ -0,0 +1,90 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R273_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R273_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.6.exe'){throw 'R273_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +# Regression for the R274 real two-VM failure: the pre-mutation 1.7.5 +# allowlist is not sufficient unless COMMITTING rollback also accepts 1.7.5. +if($null -eq $t.GetMethod('RestoreInterruptedUpgrade',$f)){throw 'R274_ROLLBACK_METHOD_ABSENT'} +$setupSource=Get-Content (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs') -Raw +$restoreBlock=[regex]::Match($setupSource,'(?s)static void RestoreInterruptedUpgrade\(List rows\)(.*?)// This works after') +if(!$restoreBlock.Success -or $restoreBlock.Groups[1].Value -notmatch 'priorVersion!=InstalledV175'){ + throw 'R274_COMMITTING_ROLLBACK_REJECTS_REAL_OFFICIAL_175' +} +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.6'){throw 'R273_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4','1.7.5')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.6"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.6"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R273_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.7","PublicVersion":"1.7.7"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R273_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v174-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V174')) +if($dict.Count -ne 22 -or $inventory.ManagedCount -ne 22){throw 'R273_OFFICIAL_174_EXACT_22_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R273_OFFICIAL_V174_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +$official175=Get-Content (Join-Path $PSScriptRoot 'official-v175-managed.json') -Raw|ConvertFrom-Json +$dict175=$legacy.Invoke($null,[object[]]@('1.7.5','OFFICIAL_STABLE_V175')) +if($dict175.Count -ne 22 -or $official175.ManagedCount -ne 22){throw 'R273_OFFICIAL_175_22_MANAGED_COUNT'} +foreach($row in $official175.Rows){ + if(!$dict175.ContainsKey($row.Path) -or $dict175[$row.Path] -cne $row.SHA256){throw ('R273_OFFICIAL175_COMPILED_SHA_DRIFT '+$row.Path)} + $cases++ +} +if([string]$official175.PublishedMainSHA256 -cne '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' -or + [string]$official175.PublishedSetupSHA256 -cne '8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC'){throw 'R273_OFFICIAL_175_PUBLIC_MAIN_SETUP_IDENTITY'} +# Retain exact previously accepted immutable v1.7.3 path as a separate ancestor. +$oldInventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json +$oldDictionary=$legacy.Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) +if($oldInventory.ManagedCount -ne 23 -or $oldDictionary.Count -ne 23){throw 'R273_HISTORICAL_V173_23_ROWS_DRIFT'} +foreach($row in $oldInventory.Rows){if(!$oldDictionary.ContainsKey($row.Path) -or $oldDictionary[$row.Path] -cne $row.SHA256){throw ('R273_V173_HISTORIC_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.6','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R273_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171','OFFICIAL_STABLE_V172','OFFICIAL_STABLE_V173')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R273_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R273_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +if($optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs','OFFICIAL_STABLE_V174'))){ + throw 'R273_OFFICIAL_V174_MUST_NOT_HAVE_OPTIONAL_ROOT_SOURCE' +} +$cases++ +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R273_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 8 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.6.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R273_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R273';Status='PASS_COMPILED_175_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official173ManagedFiles=23;Official174ManagedFiles=22;Official175ManagedFiles=22;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4','1.7.5');CurrentVersion='1.7.6';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R273_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Verify-Embedded-Main.ps1 b/build/Verify-Embedded-Main.ps1 index 9b23eb88..8780bfe4 100644 --- a/build/Verify-Embedded-Main.ps1 +++ b/build/Verify-Embedded-Main.ps1 @@ -1,4 +1,4 @@ -# R127: retain R80 independent read-only verification and accept versioned 1.7.2 patch filename. +# R127: retain R80 independent read-only verification and accept versioned 1.7.5 patch filename. # R80: independent read-only verification that a Setup's exact embedded Main PE bytes # equal the SHA256 of an independently pinned, already-signed Main distribution file. [CmdletBinding()] @@ -11,7 +11,7 @@ $ErrorActionPreference='Stop' if($ExpectedMainSHA256 -notmatch '^[0-9a-fA-F]{64}$'){throw 'R80 EXPECTED MAIN SHA NOT 64 HEX'} $path=[IO.Path]::GetFullPath($SetupExe) if(!(Test-Path -LiteralPath $path -PathType Leaf) -or - ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.2.exe'))){ + ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe','TaskbarMonitorEnhanced_Setup_1.7.8.exe'))){ throw 'R80 SETUP EXE MISSING OR WRONG FILE NAME' } $setupHash=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash diff --git a/build/Verify-Release-Metadata-R243.ps1 b/build/Verify-Release-Metadata-R243.ps1 new file mode 100644 index 00000000..5c3a3ab7 --- /dev/null +++ b/build/Verify-Release-Metadata-R243.ps1 @@ -0,0 +1,105 @@ +# R243 independent, read-only authority check for the versioned 1.7.3 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R243_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R243_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.3' -or $manifest.Build -cne 'V1_7_3_R243_CPU_PREFILTER'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.3' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.3/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.3'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.3.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.3')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R243_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.3/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R243_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R243_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R243_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/Verify-Release-Metadata-R256.ps1 b/build/Verify-Release-Metadata-R256.ps1 new file mode 100644 index 00000000..d01e8789 --- /dev/null +++ b/build/Verify-Release-Metadata-R256.ps1 @@ -0,0 +1,105 @@ +# R256 independent, read-only authority check for the versioned 1.7.4 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R256_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R256_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.4' -or $manifest.Build -cne 'V1_7_4_R256_FRESH_MAINTENANCE'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.4' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.4/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.4'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.4.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.4')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R256_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.4/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R256_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R256_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R256_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/Verify-Release-Metadata-R265.ps1 b/build/Verify-Release-Metadata-R265.ps1 new file mode 100644 index 00000000..24555833 --- /dev/null +++ b/build/Verify-Release-Metadata-R265.ps1 @@ -0,0 +1,105 @@ +# R265 independent, read-only authority check for the versioned 1.7.5 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R265_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R265_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.5' -or $manifest.Build -cne 'V1_7_5_R265_SENSOR_CROSSUSER_GUARD'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.5' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.5/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.5'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.5.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.5')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R265_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.5/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R265_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R265_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R265_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/Verify-Release-Metadata-R273.ps1 b/build/Verify-Release-Metadata-R273.ps1 new file mode 100644 index 00000000..f804d4da --- /dev/null +++ b/build/Verify-Release-Metadata-R273.ps1 @@ -0,0 +1,105 @@ +# R273 independent, read-only authority check for the versioned 1.7.8 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R273_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R273_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.8' -or $manifest.Build -cne 'V1_7_8_R286_OPTIONAL_TEMP_SOUND'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.8' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.8/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.8'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.8')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R273_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.8/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R273_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R273_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R273_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/official-v172-managed.json b/build/official-v172-managed.json new file mode 100644 index 00000000..83fa6ab3 --- /dev/null +++ b/build/official-v172-managed.json @@ -0,0 +1,128 @@ +{ + "Schema": 1, + "Source": "IMMUTABLE_PUBLIC_V172_SETUP_EXACT_VERIFIED", + "OfficialVersion": "1.7.2", + "OfficialSetupSHA256": "225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4", + "OfficialMainSHA256": "84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F", + "ManagedCount": 23, + "ComparedExistingFiles": 22, + "OwnerPrivateDataIncluded": false, + "OptionalRootDuplicate": "TaskbarMonitorEnhanced.cs", + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.cs", + "SHA256": "E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "Optional": true + }, + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "Optional": false + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Optional": false + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Optional": false + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "B71B4F1CD41A12A82DC470534563D2EA24FAAF38233D0158AC13085EF7F75ED0", + "Optional": false + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Optional": false + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Optional": false + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "76C65D5A16F384B7600EB4BDEDE7551283235CA32A662A248AF6A6DE03CD5A35", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "A3AD433D281894BB514F225ADEBA007B5F2E8641A1337BF57DE5248D2F96246A", + "Optional": false + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Optional": false + }, + { + "Path": "Docs/README.md", + "SHA256": "FE7F6354C295B71607D14AB5D51843C2C951B2E984399FA5AA00BBF8AA4F8F0E", + "Optional": false + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Optional": false + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Optional": false + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Optional": false + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Optional": false + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.2.md", + "SHA256": "088EEAF5EE5200CE9686134CBD15F5C6C9436E8027E9DF1613190762911D01FA", + "Optional": false + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Optional": false + }, + { + "Path": "Uninstall.exe", + "SHA256": "225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4", + "Optional": false + } + ] +} \ No newline at end of file diff --git a/build/official-v173-managed.json b/build/official-v173-managed.json new file mode 100644 index 00000000..9653a0b6 --- /dev/null +++ b/build/official-v173-managed.json @@ -0,0 +1,129 @@ +{ + "Status": "PASS_PINNED_PUBLIC_V173_MANAGED_RESOURCE_FOR_FUTURE_UPGRADE", + "Schema": 1, + "Version": "1.7.3", + "SourceCommit": "6001fce0e7900f2a1ec5172224f4c75224f3ac69", + "PublicSetupSHA256": "9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B", + "PublicMainSHA256": "6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C", + "ManagedCount": 23, + "OwnerUpgradedManagedMatched": 22, + "RootSourceOptionalSHA256": "5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "PrivateConfigIncluded": false, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.cs", + "SHA256": "5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "Optional": true + }, + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "Optional": false + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Optional": false + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Optional": false + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Optional": false + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Optional": false + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Optional": false + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "F11D20C67056AC054C690C630044F717657DAB6FE26E5997EEBFF25947CF69B9", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "25C2E5BD69BC4210757695501606942ED3ADD0C250848A2CC0B6AB4D8070578F", + "Optional": false + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Optional": false + }, + { + "Path": "Docs/README.md", + "SHA256": "48026FC6A842A6BAEC519E04555A7291CD3DE339CBE59B9AAE635AAA1C1E8D37", + "Optional": false + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Optional": false + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Optional": false + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Optional": false + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Optional": false + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.3.md", + "SHA256": "CA1A8889BA56AA802BD9A2E2C0A45F885004DB5868702EBAE1076BF52C0F7683", + "Optional": false + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Optional": false + }, + { + "Path": "Uninstall.exe", + "SHA256": "9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B", + "Optional": false + } + ] +} \ No newline at end of file diff --git a/build/official-v174-managed.json b/build/official-v174-managed.json new file mode 100644 index 00000000..8c611509 --- /dev/null +++ b/build/official-v174-managed.json @@ -0,0 +1,102 @@ +{ + "Status": "PASS_EXACT_PUBLIC_V174_MANAGED_FILES_PINNED_FROM_RELEASED_SETUP", + "Schema": 1, + "Version": "1.7.4", + "SourceCommit": "1375d719989aa93966bae445ac63b9a415f637ad", + "PublicSetupSHA256": "C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1", + "PublicMainSHA256": "279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C", + "ManagedCount": 22, + "OwnerInstalledAllManagedMatched": true, + "IncludesUserConfig": false, + "OptionalAbsentRootSourceSupported": false, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C" + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F" + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF" + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "24460E03A325FD3F085302EAED0DDD7511BAD71F9DE1C722EDD61408CA754CF9" + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD" + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D" + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF" + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19" + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65" + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "23B946D913810FB074F2C904CFF133A44948C57724818E3250B8E21B4D519E37" + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B" + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B" + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "957B655CC028F4D68EB88A430CA4330107D7623004585601233DABD67ABED6B2" + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986" + }, + { + "Path": "Docs/README.md", + "SHA256": "0D5CC4DAE08C3131DCCC095109EDEA997C757DE65E7A8BA296A2F399E5B67292" + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40" + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153" + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB" + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559" + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.4.md", + "SHA256": "FF604D0B69C2116CD1A72C08189FCB18ACA5435894E3FECD822FE972CA996E07" + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293" + }, + { + "Path": "Uninstall.exe", + "SHA256": "C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1" + } + ] +} \ No newline at end of file diff --git a/build/official-v175-managed.json b/build/official-v175-managed.json new file mode 100644 index 00000000..57070a8e --- /dev/null +++ b/build/official-v175-managed.json @@ -0,0 +1,121 @@ +{ + "Schema": 1, + "Status": "PINNED_OFFICIAL_V175_OWNER_INSTALLED_MATCH_PUBLIC_SETUP_MAIN", + "Version": "1.7.5", + "SourceCommit": "9284b124168f3aa2c90f8637eeba6a20f2d0e1af", + "PublishedSetupSHA256": "8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", + "PublishedMainSHA256": "25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "ManagedCount": 22, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "Bytes": 628224 + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Bytes": 456 + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Bytes": 22172 + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "53A37483D56C20A28FB4FA4D8DC207F3B3E06E081F32B7506C8438E321EC0C9E", + "Bytes": 596040 + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Bytes": 15378 + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Bytes": 26913 + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Bytes": 101274 + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Bytes": 22796 + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Bytes": 55753 + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "C9872DAA819E9CA7453C158F5A4CB4CAB16FB1078606EE0BAF568555DBE68E57", + "Bytes": 21949 + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Bytes": 28046 + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Bytes": 51057 + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "66626A8A762ED34EDC91C7C3D03AD9889CAA5A8FEB64E973EF0EF0E08CDC8AF8", + "Bytes": 95554 + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Bytes": 35149 + }, + { + "Path": "Docs/README.md", + "SHA256": "74955DE4D73DE9D7AD1C394187E6ACFC95013A92799D8BA9BB7DBDC5F4E7D701", + "Bytes": 496 + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Bytes": 803 + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Bytes": 1174 + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Bytes": 674 + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Bytes": 2224 + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.5.md", + "SHA256": "AD3642ABD89BA1C31B2EEA83847FC3ED8861AEBFF9E664B5169A287601AAA16A", + "Bytes": 1811 + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Bytes": 811 + }, + { + "Path": "Uninstall.exe", + "SHA256": "8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", + "Bytes": 8524800 + } + ] +} \ No newline at end of file diff --git a/build/official-v176-managed.json b/build/official-v176-managed.json new file mode 100644 index 00000000..3896e7f4 --- /dev/null +++ b/build/official-v176-managed.json @@ -0,0 +1,121 @@ +{ + "Schema": 1, + "Status": "PINNED_OFFICIAL_V176_OWNER_INSTALLED_MATCH_PUBLIC_SETUP_MAIN", + "Version": "1.7.6", + "SourceCommit": "2348791fbde3aff5a18b7df536dba8a66a65d191", + "PublishedSetupSHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + "PublishedMainSHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", + "ManagedCount": 22, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", + "Bytes": 636416 + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Bytes": 456 + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Bytes": 22172 + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "086B21052E050B56B753634918D5279C1CAFE4918FE8F2D4FB6F4DC05CF040D5", + "Bytes": 597062 + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Bytes": 15378 + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Bytes": 26913 + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Bytes": 108873 + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Bytes": 22796 + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Bytes": 55753 + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "2A3C0152CB8727B8F5AD281AAAA730F12C8D6CBC667866C751C3B866026563B1", + "Bytes": 21949 + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Bytes": 28046 + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Bytes": 51057 + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "EFC7104361C855FBE40A673A7CFBD868AAFE663ADC4F465D80A69E8D0E771F7C", + "Bytes": 98969 + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Bytes": 35149 + }, + { + "Path": "Docs/README.md", + "SHA256": "9CCB3096C6697429527A24E0E2105E5CD7E6B76CEB39B70C8169570D8D796675", + "Bytes": 450 + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Bytes": 803 + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Bytes": 1174 + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Bytes": 674 + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Bytes": 2224 + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.6.md", + "SHA256": "D8CF4A1F6596A043E4718E787D0098E4A10ED2D996CEF8766FC268DB72E620E7", + "Bytes": 1664 + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Bytes": 811 + }, + { + "Path": "Uninstall.exe", + "SHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + "Bytes": 8547328 + } + ] +} \ No newline at end of file diff --git a/build/official-v177-managed.json b/build/official-v177-managed.json new file mode 100644 index 00000000..defc67f5 --- /dev/null +++ b/build/official-v177-managed.json @@ -0,0 +1,121 @@ +{ + "Schema": 1, + "Status": "PINNED_OFFICIAL_V177_INSTALLED_EQUALS_PUBLIC_RELEASE_PAYLOAD", + "Version": "1.7.7", + "SourceCommit": "dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad", + "PublishedSetupSHA256": "395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + "PublishedMainSHA256": "A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", + "ManagedCount": 22, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", + "Bytes": 636416 + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Bytes": 456 + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Bytes": 22172 + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "B95546C73093486D54395FBEE891002A49913F9234713C16A11DCAC23EDFD962", + "Bytes": 597067 + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Bytes": 15378 + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Bytes": 26913 + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Bytes": 108873 + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Bytes": 22796 + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Bytes": 55753 + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "7D1B3A1FA0A46CAC47D0063E2ED7929C5299F2DE0FD394BD6C82E53B72590FA5", + "Bytes": 21949 + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Bytes": 28046 + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Bytes": 51057 + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "65284119FE8759314BDDD3309837B2377AB6682BE98DF3995363908822A4CA38", + "Bytes": 102954 + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Bytes": 35149 + }, + { + "Path": "Docs/README.md", + "SHA256": "F136012748AD1D1560EC5D5342D2D1228E5A90EE59B094ECA0E8D3CCAB51A63F", + "Bytes": 495 + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Bytes": 803 + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Bytes": 1174 + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Bytes": 674 + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Bytes": 2224 + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.7.md", + "SHA256": "74FDDEBDDE733F1F0E2BE2C84824E4784D4CC244094660199094DA5550091A83", + "Bytes": 1544 + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Bytes": 811 + }, + { + "Path": "Uninstall.exe", + "SHA256": "395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + "Bytes": 8553984 + } + ] +} \ No newline at end of file diff --git a/docs/project-control/CURRENT_PROJECT.json b/docs/project-control/CURRENT_PROJECT.json index 95e4d549..5b182918 100644 --- a/docs/project-control/CURRENT_PROJECT.json +++ b/docs/project-control/CURRENT_PROJECT.json @@ -1,63 +1,75 @@ { "Schema": 1, "Project": "TaskbarMonitorEnhanced", - "CanonicalRoot": "C:\\Users\\Aa.Emad\\source\\repos\\TaskbarMonitorEnhanced", - "InstalledRoot": "C:\\Users\\Aa.Emad\\AppData\\Local\\TaskbarMonitorEnhanced", - "CurrentEngineeringRevision": "R40", - "Status": "CURRENT_PACKAGE_CANDIDATE_ACCEPTED_NOT_INSTALLED", - "UpdatedUtc": "2026-10-05T17:03:48.6381459Z", - "FinalObjective": "Deliver v1.7.0 Wallpaper Studio with 21 Studio themes including Sekiro and exactly 10 wallpapers per Studio theme, preserving the accepted v1.6.0 runtime until promotion gates pass.", - "DefinitionOfDone": [ - "21 Studio themes including Sekiro", - "10 validated wallpapers per Studio theme (210 total)", - "preview/export/apply UX", - "reversible desktop apply pre-state/rollback", - "candidate source tests PASS", - "v1.7 package/installer integrated", - "clean-clone determinism and visual package acceptance PASS", - "single reversible live wallpaper canary PASS", - "installed v1.7 runtime regression PASS", - "release/CI promotion PASS if publication is authorized" - ], - "PreviousAcceptedState": { - "Version": "1.6.0", - "Revision": "R38", - "Status": "RELEASED_ACCEPTED_IMMUTABLE", - "SourceSHA": "1652989b80c5c6d301c6f05818c48c5207fd9b7a", - "InstalledMainSHA256": "05F9E069C4598FDAD327A41BF1CA34A1498D022119DB81E09F4B3B091C9BA377" - }, - "CurrentCandidate": { - "Branch": "feature/v1.7.0-wallpaper-studio", - "ProductSourceCommit": "ed46152eb9d9ff326687ba5038aa0990e03102d0", - "PackagingCommit": "2cb0baadb127c4b9e8090670d51905ffd2e29627", - "SourceAcceptance": "docs/acceptance/v1.7.0/R39_SOURCE_CANDIDATE_ACCEPTANCE.json", - "PackageAcceptance": "docs/acceptance/v1.7.0/R40_PACKAGE_CANDIDATE_ACCEPTANCE.json", - "ThemesTotal": 49, - "StudioThemes": 21, - "WallpapersPerStudioTheme": 10, - "StudioWallpapers": 210, - "SetupSHA256": "58E844297A0B33E52F05D5023408DB159275BAECDB5C1627DBAEA990CF1237AF", - "Installed": false, - "Released": false + "AuthorityIndex": "docs/project-control/PROJECT_BRAIN.md", + "Scope": "Redacted public summary only, not the owner-local authoritative Project Brain. Official single per-user product release accepted under unsigned waiver; machinewide singleton not asserted.", + "CurrentEngineeringRevision": "R285", + "Status": "PUBLIC_REDACTED_MIRROR_R285_IMMUTABLE_V177_REAL_OWNER_INPLACE_SCOPED_DELIVERED", + "UpdatedUtc": "2026-10-16T21:48:03.585645+00:00", + "FinalObjective": "Preserve v1.6 taskbar behavior with user-enabled zero-background Glass Mode, correct Start/Settings/fullscreen handling, safe in-place upgrades, a single per-user installation root, and reliable low-overhead sensors.", + "CurrentAcceptedBaseline": { + "Version": "1.7.7", + "ReleaseId": 415382091, + "ReleaseURL": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.7", + "SourceCommit": "dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad", + "SetupSHA256": "395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + "InstalledMainSHA256": "A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", + "PublicAssetCount": 7, + "InstallerUnsigned": true, + "PerUserInstall": true, + "MachinewideSingletonProven": false, + "OwnerObservedGlassModeZeroAlpha": true, + "OwnerHighCpuRealRedPixelsConfirmed": true, + "OwnerWindowsNotificationsOptedIn": true, + "OwnerApplicationNotificationDispatchLogged": true, + "ActualOsToastOrSoundProven": false, + "OwnerLiveV177VersionObserved": true, + "MainBuildCI": 38965519302, + "NativeHostedActualSetupKillRecoveryCI": 38965519445, + "IndependentPublicSevenAssetG16Passed": true, + "OwnerPerUserUpgradeRealExitZero": true, + "OwnerOldVersionRecoverable26Files": true, + "HistoricalRedPixelProofVersion": "1.7.6", + "Current177ActualRedPixelsIndependentlyCounted": false }, "Completed": [ - "R39 source implementation and pixel validation", - "R40 v1.7 installer/build identity integration", - "WallpaperStudio canonical source embedded and extraction path verified", - "Setup /verify PASS with 25 resources", - "exact-commit full build PASS 0 warnings / 0 errors", - "clean-clone determinism PASS", - "visual candidate suite PASS 8/8", - "workspace proof PASS", - "wallpaper proof PASS 210/210 unique" + "Seven immutable public v1.7.5 assets independently SHA/Manifest verified (R267)", + "Exact-main Windows A/B build, fresh/upgrade/rollback, native UI and cross-user guard CI PASS (R265)", + "Owner official v1.7.4 to v1.7.5 in-place upgrade and 443-file cold backup/isolated restore PASS (R268)", + "Owner unmanaged data preserved, Glass native observations and unpaired 60.688-second CPU observation recorded (R269)", + "Owner current-user scheduled-task principal and all four live process SIDs matched; installed hashes retained (R270)", + "R272-R274 semantic Glass red-warning compositor correction across 98 actual bitmap tests; safe official v1.7.5→v1.7.6 transactional update and rollback on two hosted Windows A/B.", + "R275 immutable public unsigned v1.7.6: one Setup, seven assets independently SHA/manifest verified by live G16; exact merged-main CI and Windows A/B SUCCESS.", + "R276 owner official same-root 1.7.6 update with private 26-entry preupgrade recovery, real hot CPU Glass 3243 red pixels, Windows notification opt-in plus app dispatch, 10/10 healthy sensors.", + "R285 public mirror: v1.7.7 immutable seven assets and exact-source CI, real owner 1.7.6→1.7.7 same-root update, live CPU threshold dispatch, old-version backup; no unproven audio/security/global claims." ], "Open": [ - "single live apply/verify/restore canary", - "installed v1.7 runtime acceptance", - "release/CI gates" + "ISSUE6_REPORTER_DRIVER_SHA_AND_INSTALL_ORIGIN_MISSING", + "OWNER_APP_OS_NOTIFICATION_BANNER_AND_SOUND_NOT_INDEPENDENTLY_WITNESSED" ], - "CriticalPath": "single reversible wallpaper canary -> installed v1.7 acceptance -> release/CI", - "MainBlocker": "Live desktop apply/restore behavior is not yet proven on the host; package promotion to installed v1.7 is blocked until one controlled canary returns the exact desktop pre-state.", - "BrainStatus": "CURRENT_R40_PACKAGE_CANDIDATE", - "ExactNextAction": "R41 only: capture wallpaper/GUI/AnyDesk pre-state, apply one Sekiro wallpaper from exact candidate 2cb0baa, verify it, restore the exact prior wallpaper/style/tile, and verify host stability." + "CriticalPath": "Immutable v1.7.7 public source dc68cf4 and seven SHA-verified assets PASS; native Windows A/B actual Setup interruption/recovery and 27-step Build CI PASS; owner same-root per-user 1.7.6 to 1.7.7 real upgrade and live Glass/alert dispatch verified. Reporter Issue #6 and OS audible/visible notification independent witness remain external. Owner excluded physical AC interruption.", + "MainBlocker": "No in-scope released app blocker. Reporter-specific WinRing0 source/hash is unavailable (Issue #6); an independent live owner-app OS notification banner/audio witness is also absent. Neither optional global multiuser policy nor waived signing nor excluded AC outage blocks the current per-user release.", + "BrainStatus": "PUBLIC_REDACTED_DERIVATIVE_R285_NOT_A_SECOND_OPERATIONAL_BRAIN", + "ExactNextAction": "Use the one official immutable v1.7.7 Setup for new per-user installations or supported in-place updates. Never bypass Defender; original reporter WinRing0 driver SHA/download path requested in Issue #6. Verify owner-app OS banner/speaker on a natural threshold event without heating hardware. No physical AC test is required.", + "PublicTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7", + "SecurityTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6", + "HistoricalR40Record": { + "Commit": "9284b124168f3aa2c90f8637eeba6a20f2d0e1af", + "Path": "docs/project-control/CURRENT_PROJECT.json", + "GitBlob": "95e4d549d855de787a9274888bc686e3fda54d20", + "Role": "immutable historical evidence, superseded by current R276" + }, + "HistoricalOfficialV176": { + "Version": "1.7.6", + "ReleaseId": 415147285, + "SourceCommit": "2348791fbde3aff5a18b7df536dba8a66a65d191", + "SetupSHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + "MainSHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8" + }, + "AcceptedScopeExclusions": [ + "PHYSICAL_AC_MAINS_INTERRUPTION_TEST_REMOVED_BY_OWNER", + "MACHINEWIDE_MULTISUPERUSER_GLOBAL_SINGLETON_NOT_REQUESTED", + "AUTHENTICODE_PUBLISHER_SIGNING_WAIVED_FOR_UNSIGNED_RELEASE", + "INDEPENDENT_SECURITY_LAB_CLEARANCE_NOT_CLAIMED" + ] } diff --git a/docs/project-control/PROJECT_BRAIN.md b/docs/project-control/PROJECT_BRAIN.md index a1aea338..892e92b7 100644 --- a/docs/project-control/PROJECT_BRAIN.md +++ b/docs/project-control/PROJECT_BRAIN.md @@ -1,3 +1,22 @@ + +# Current public release summary — R285 / official v1.7.7 (2026-10-17) + +Official immutable [v1.7.7 release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.7), ID 415382091, source commit dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad. Single intentionally unsigned Setup SHA256 395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D; shipped/owner Main SHA256 A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253. Exactly seven public assets independently re-downloaded against Manifest, SHA256SUMS and G16. This verifies content/publication, **not** independent malware certification. + +**Interruption bug fixed:** Real old v1.7.6 Setup process was killed during rollback on a disposable Windows guest; the retry failed due to abandoned owned .restore scratch. Versioned 1.7.7 safely recovers only known updater-owned transient scratch after verifying the journal/index and old snapshots. Exact-main [Build 38965519302](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38965519302) PASS 27 steps; [hosted native Windows A/B 38965519445](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38965519445) PASS including a real Setup-process kill in guest B. Real AC mains interruption was not tested and has been **excluded by the owner**. + +**Actual owner system:** The verified existing v1.7.6 per-user installation was updated in place with the official 1.7.7 Setup, Exit 0, and launched again. A preupgrade 26-file SHA-verified protected backup was preserved; exact five build-output hashes, one Main/one Supervisor/two Brokers, one HKCU registration, no extra HKLM install and cleared journal were verified. Glass true zero background alpha, red threshold warning settings and Windows notification preference remain enabled. v1.7.7 log records a real CPU 95°C warning dispatch at configured 85°C; direct actual OS banner/audio was **not independently witnessed**. Earlier R276 physical red pixels were verified on v1.7.6, and current behavior is retained by compiled regression/owner settings; no false claim of a new v1.7.7 physical pixel count. + +**Scope and residual limits:** [Reporter WinRing0 issue #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) still needs original file SHA256/install source, and a signed local PawnIO version does not dismiss that separate reporter warning. Publisher signing is intentionally waived. One **per-user** installation is accepted; no machinewide global singleton/auto-login claim. Audible alert observation remains missing. Physical AC tests are not a required gate. + +The owner-local Index/Brain remains the single operational authority. This public page and [Issue #7](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7) are redacted mirrors; older engineering records following this block are preserved unchanged. + + + +--- + +## Historical engineering records — prior "CURRENT" headings are not current release claims + # CURRENT R56 — real Start/Search 24/24 PASS; active Native ShellBeacon contract corrected, exact CI/host retest pending R54 exact source de6d2bb3025b551dabfd0bd792fb368a1d6f519c, 12 candidate gates plus 4 Stable blockers. R55 actual installed-path Main-only guarded seven-minute watchdog test on Emad with real CPU/RAM/GPU/network showed 2895 actual text and 2695 graph pixels. Full resident eight-case suite got 7 PASS: startup, hover, hardware, temperature, protected sensor health, support and START. START met 24/24 SearchHost real shell, 24/24 exact 60px safe float and actual per-frame glyph+chart ink, minimum 48647 foreground signal, ≥1492 text and 1625 chart changed pixels, exact native HWND return to taskbar with legible ink. Eighth ShellState FAIL DIRECT_CHILD_NOT_FOUND: stale child-only proof, expected since true-alpha 0–99% uses a top-level popup. R55 original public installed v1.6 exe/config hashes restored exact, old process running, personal Slideshow and AnyDesk protected; genuine 7/8 FAIL retained. R56 source ShellState now distinguishes real PID-owned native top-level layered/noactivate popup vs 100% opaque taskbar child; native PArgb active beacon performs synchronous real ULW upload, requires magenta/green ≥100 physical pixels and BOTH reset below100, plus real signal≥300, style/PID/geometry/uncloak. Code builds locally, actual installed eight-case retest and exact source CI pending. All external Stable gates still OPEN: approved real Picture WallpaperStudio positive apply/restore, complete installed v1.7 Setup incl Explorer/sensors/8/8 and rollback, public-trusted Authenticode, main+immutable release. Public version v1.6.0 immutable. User Slideshow mode2 untouched, SAEID host declined, no security bypass. Authority docs/project-control/R56_SHELL_BEACON_NATIVE_ACCEPTANCE.md and R54/R51/R49/R46 history, cumulative Brain R38–R55. Project weighted completion UNPROVEN. diff --git a/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md b/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md index 389aed82..65c2a585 100644 --- a/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md +++ b/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md @@ -1,4 +1,13 @@ # Project Knowledge / Evidence Record — Taskbar Monitor Enhanced + +## 2026-10-17 / R285 — release and owner verification public summary (redacted derivative) + +- Versioned crash fix: private/native Windows-hosted actual v1.7.6 Setup interruption exposed a stuck updater-owned .restore scratch; R284 v1.7.7 reconstructs only owned scratch after journal/index validation. PR #76 merged dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad; exact-main Build CI 38965519302 and Windows A/B crash recovery CI 38965519445 PASS. Real physical power cut was excluded by owner. +- Public immutable seven-asset v1.7.7 GitHub release ID 415382091, Setup SHA 395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D, Main SHA A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253; independent artifact G16 PASS. Official v1.7.6 retained unchanged. +- Real owner same-root one-per-user v1.7.6→v1.7.7 upgrade Exit 0; 26 original managed/protected files archived, all five shipped build outputs matched, config and Glass zero-alpha preserved, one Main/one Supervisor/two Brokers, no second HKLM install. Actual CPU 95C app notification dispatch logged; OS audio/banner not independently heard or seen. +- Outstanding external items only: original reporter WinRing0 file SHA/source (Issue #6) and actual owner-app OS alert presentation/audio observation. Global machinewide singleton and signed-publisher attestation are explicitly outside accepted per-user unsigned scope; do not disable Defender. + + ## 2026-09-26 — R22 final identity/build/install/runtime acceptance diff --git a/docs/project-control/README.md b/docs/project-control/README.md index 1c42bd39..3e05f941 100644 --- a/docs/project-control/README.md +++ b/docs/project-control/README.md @@ -1,8 +1,9 @@ # Project control -This directory contains cumulative project-governance records used to preserve technical decisions, evidence lineage, failure-prevention knowledge, and release closeout state. +This directory preserves release-engineering decisions and historical verification evidence. It is **not part of the installed application**. -- [Project Brain](PROJECT_BRAIN.md) -- [Project Knowledge & Evidence](PROJECT_KNOWLEDGE_EVIDENCE.md) +**Current public status (R276 / v1.7.6):** [public state](CURRENT_PROJECT.json), [Project Brain summary and history](PROJECT_BRAIN.md), [verified release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.6), and [rolling public tracker / open gates](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7). [Security report #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) remains open. -These records are not runtime components and are not release downloads. +[Project Knowledge & Evidence](PROJECT_KNOWLEDGE_EVIDENCE.md) retains dated engineering evidence. Historical headings such as “CURRENT R56” were current **only at the time recorded**. The complete owner-local Project Brain is the single active private operational authority; these Git-tracked files are a redacted **public summary and historical record**, not a second live operational Brain. Original historical file versions remain retrievable from Git. + +No documentation update changes an immutable release, installed binary, or security verdict. diff --git a/docs/releases/v1.7.3/INSTALLER_README.txt b/docs/releases/v1.7.3/INSTALLER_README.txt new file mode 100644 index 00000000..a41dc35d --- /dev/null +++ b/docs/releases/v1.7.3/INSTALLER_README.txt @@ -0,0 +1,7 @@ +Taskbar Monitor Enhanced 1.7.3 +Single per-user installation under LocalAppData/TaskbarMonitorEnhanced. +One versioned Setup performs in-place upgrade from verified official v1.7.2 without uninstall or data loss. +Close the running monitor normally before launching the updater. +Your Glass ON background is fully transparent; OFF restores its separately saved opacity. +This release is intentionally unsigned. An exact SHA-256 confirms identity, not malware clearance or public signing. +Review Release Notes for test limitations and external security reports. diff --git a/docs/releases/v1.7.3/RELEASE_NOTES.md b/docs/releases/v1.7.3/RELEASE_NOTES.md new file mode 100644 index 00000000..87ca6bf6 --- /dev/null +++ b/docs/releases/v1.7.3/RELEASE_NOTES.md @@ -0,0 +1,14 @@ +# Taskbar Monitor Enhanced v1.7.3 — CPU efficiency and verified in-place update + +## Changes +- Reordered existing Start/Search native-window geometry filters ahead of expensive Windows process-name enumeration; no layout behavior change intended. +- The separate user-controlled **Glass Mode** ON uses zero-opacity background while metric ink remains visible. OFF restores the saved 1–100% background opacity; Start is pinned to the actual taskbar and never intentionally floated. +- **Glass Mode switch visual repair:** the switch and its On/Off label are separate opaque controls, with no double-painted native text on the thumb. The Display preview no longer overlaps its explanation. All nine Settings pages have a layout-collision regression guard. +- One SHA-pinned Windows Setup updates an existing official v1.7.2 installation **in place**, without uninstalling or creating a second per-user root. Existing official v1.6.0/1.7.0/1.7.1 compatible paths remain guarded. + +## Verification and scope +- On one owner Windows PC, actual 4 × 60-second BASE–TRIAL–TRIAL–BASE whole-program CPU observation measured 23.37809% versus 6.65184% of one logical core (71.547% lower in those tested conditions), exact prior v1.7.2 Main restored. +- The owner's quiescent 442-file data-root ZIP passed SHA checks and byte-exact isolated restore; this does not establish physical AC power-loss resilience or restore a deliberately damaged owner HKCU registry. +- Candidate verification (Commit `690cef8d6883e7811913a394343fa0e304ccb87a`): independent Windows A/B protected v1.7.2 → v1.7.3 in-place upgrades, journal rollback, idempotence, downgrade refusal and four native Start/Settings/fullscreen/presentation modes passed; standard Build and Verification, exact binary/SBOM, and nine-page no-overlap UI suite passed. Public promotion requires equivalent merged-main exact-head CI, asset provenance and uploaded SHA-256 acceptance. +- The installer is **unsigned** by maintainer choice. Unknown publisher/SmartScreen messages are not proof of malware; actual antivirus detections must not be bypassed or dismissed. External vulnerable-driver issue #6 attribution remains unresolved, as do formal signed labs, physical power loss, and machine-wide multiuser/HKLM collision proof. +- Public immutable v1.7.2 assets are not altered by this new release. diff --git a/docs/releases/v1.7.4/INSTALLER_README.txt b/docs/releases/v1.7.4/INSTALLER_README.txt new file mode 100644 index 00000000..f23f4a49 --- /dev/null +++ b/docs/releases/v1.7.4/INSTALLER_README.txt @@ -0,0 +1,6 @@ +Taskbar Monitor Enhanced 1.7.4 +One per-user installer; guarded in-place update of official 1.7.3 sources, with or without its legacy redundant root source. +Fresh installs store canonical program source only under Source/. +Existing Glass settings and arbitrary user data are preserved; no uninstall-before-upgrade. +Unsigned by existing owner policy; user may see SmartScreen publisher reputation notice. Do not bypass actual antivirus detections. +See Release Notes for verified test boundaries. diff --git a/docs/releases/v1.7.4/RELEASE_NOTES.md b/docs/releases/v1.7.4/RELEASE_NOTES.md new file mode 100644 index 00000000..24962b19 --- /dev/null +++ b/docs/releases/v1.7.4/RELEASE_NOTES.md @@ -0,0 +1,11 @@ +# Taskbar Monitor Enhanced v1.7.4 — fresh-install maintenance and protected upgrades + +## Fix +- Prevent the redundant root-level TaskbarMonitorEnhanced.cs file during fresh install while retaining the canonical Source/TaskbarMonitorEnhanced.cs file. This resolves the genuine fresh v1.7.3 same-version maintenance defect, independently reproduced on Windows 2025 and 2022. +- Include SHA-pinned official v1.7.3 as a supported source for same-root v1.7.4 in-place upgrade, regardless of whether it was installed fresh (redundant root source present) or upgraded from v1.7.2 (root source absent). Any present legacy root source must match the official SHA before managed removal. +- Glass transparency, separate On/Off switch status label, protected user config and backup, native taskbar geometry and CPU optimizations remain as accepted in v1.7.3. + +## Release gates and scope +- Do not release until exact code head builds reproducibly with its own verification tests and two independent fresh Windows instances cover actual fresh 1.7.4 install/maintenance, official v1.7.3 fresh and upgraded to 1.7.4, native Start/Settings/Glass/fullscreen and protected user canaries with rollback. +- The public v1.7.3 release remains immutable; no silent binary replacement and no duplicate per-user installed root. +- Intentionally unsigned; SmartScreen reputation is distinct from antivirus vulnerability detection. Microsoft vulnerable WinRing0 Issue #6 attribution is unresolved. Never disable Defender, bypass quarantines or claim malware-free status. Physical power cut, HKLM multiple-user and external signing/lab are not accepted merely from these CI tests. diff --git a/docs/releases/v1.7.5/INSTALLER_README.txt b/docs/releases/v1.7.5/INSTALLER_README.txt new file mode 100644 index 00000000..783ee897 --- /dev/null +++ b/docs/releases/v1.7.5/INSTALLER_README.txt @@ -0,0 +1,6 @@ +Taskbar Monitor Enhanced 1.7.5 +Single per-user installer, exact official 1.7.4 same-root update with protected rollback and no data erasure. +Cross-user sensor safety guard: never take over another user's protected Task Scheduler sensor task. +Fresh setup remains per-account; machinewide single-install is not enforced. +Glass ON transparency and user configuration are protected during update. +Unsigned as previously accepted; respect Microsoft Defender detections, do not disable security tools. diff --git a/docs/releases/v1.7.5/RELEASE_NOTES.md b/docs/releases/v1.7.5/RELEASE_NOTES.md new file mode 100644 index 00000000..9ca09061 --- /dev/null +++ b/docs/releases/v1.7.5/RELEASE_NOTES.md @@ -0,0 +1,12 @@ +# Taskbar Monitor Enhanced v1.7.5 — cross-user sensor-layer safety + +## Critical fix +- Prevent one Windows account's elevated sensor setup/uninstall from unregistering the machinewide sensor Scheduled Task, terminating sensors, or removing shared Program Files sensor payload owned by a different account. +- Before any shared mutation resolve the target SID, verify the existing task principal and all observable broker/supervisor process owners; on foreign or unknown ownership, fail closed and leave protected sensors unchanged, while allowing the current user's main app and independent settings to work with unavailable protected readings if necessary. +- Fresh no-task installs and same-account repairs remain permitted. This is a safety guard, **not** global single-install enforcement, task per-SID migration, or permission to disable antivirus. + +## Installer and delivery +- Single 64-bit Windows per-user Setup; exact SHA-pinned immutable official v1.7.4 22 managed rows supported for same-root v1.7.5 update with atomic rollback. Existing v1.6–v1.7.3 official upgrade sources retained. +- Preserves Glass alpha-zero mode, separate On/Off state label, Start/Settings/fullscreen, user's config/previous backup, startup registration and uninstaller state. +- Intentionally unsigned under prior publisher waiver. Microsoft genuine vulnerable-driver WinRing0 Issue #6 attribution awaits the reporter's exact driver SHA/install source and independent review. Physical AC power loss and cross-account actual full protected-sensor cohabitation are not claimed. +- Release only after exact merged source Build/metadata/security/rollback and independent two-host Windows A/B official v1.7.4→v1.7.5 and fresh install maintenance tests; verify and redownload all 7 public artifacts before owner in-place upgrade. diff --git a/docs/releases/v1.7.6/INSTALLER_README.txt b/docs/releases/v1.7.6/INSTALLER_README.txt new file mode 100644 index 00000000..e6137f18 --- /dev/null +++ b/docs/releases/v1.7.6/INSTALLER_README.txt @@ -0,0 +1,5 @@ +Taskbar Monitor Enhanced 1.7.6 +Single per-user installer, exact SHA-checked official 1.7.5 in-place update with rollback and no user data erasure. +Glass mode keeps hot CPU/GPU/disk warnings RED while the taskbar background stays fully transparent. +Windows temperature notification balloons are opt-in under Settings > Alerts. +Cross-user sensor guard preserved; no machinewide single-install guarantee. Intentionally unsigned. Do not bypass Defender. diff --git a/docs/releases/v1.7.6/RELEASE_NOTES.md b/docs/releases/v1.7.6/RELEASE_NOTES.md new file mode 100644 index 00000000..f255b761 --- /dev/null +++ b/docs/releases/v1.7.6/RELEASE_NOTES.md @@ -0,0 +1,19 @@ +# Taskbar Monitor Enhanced v1.7.6 — glass thermal warning hotfix + +## Bugfix +- On user-enabled fully transparent Glass mode, retain the red thermal warning text, chart and card border when a valid CPU, GPU or disk temperature meets the configured threshold. +- Normal metric glyphs remain theme-adaptive and visible, while background alpha stays 0%. No new driver or sensor dependency. + +## Notification behavior +- Thermal highlight and Windows notification balloons are separate features. In Settings > Alerts, enable **Show Windows notification when a threshold is crossed** for notifications; previously disabled preferences are preserved. +- Existing default alert thresholds CPU/GPU 85°C, disk 60°C, hysteresis and cooldown remain unchanged. +- No audible OS notification is promised when Windows Do Not Disturb or notification policies mute alerts. + +## In-place update +- Same canonical per-user installation root and existing SHA-pinned previous releases supported; v1.7.5 added as an exact 22-file + signed-off Setup SHA ancestor. +- Keep config, config.bak, history, user assets and HKCU autostart. Journal and recover interrupted upgrades; no forced process termination, no duplicate root. +- v1.7.5's cross-user privileged task safety guard retained. No claim of machinewide singleton or physical AC outage recovery. +- Intentionally unsigned under established waiver. Defender WinRing0 Issue #6 remains open and unrelated to this visual bugfix. + +## Verification +- Exact source build and 49 × 2 pixel warning proof plus hosted Windows full CI; real v1.7.5 -> v1.7.6 install/rollback on two disposable Windows required before publication and owner's update. diff --git a/docs/releases/v1.7.7/INSTALLER_README.txt b/docs/releases/v1.7.7/INSTALLER_README.txt new file mode 100644 index 00000000..764db749 --- /dev/null +++ b/docs/releases/v1.7.7/INSTALLER_README.txt @@ -0,0 +1,7 @@ +Taskbar Monitor Enhanced v1.7.7 +One per-user Windows installer. Updates the SHA-pinned official v1.7.6 in place, +preserving configuration, sensor state, charts, and startup preferences. +Fix: interrupted updater-owned .restore scratch no longer blocks a safe recovery. +Glass fully transparent backgrounds and semantic hot-temperature red remain. +External PawnIO not bundled. No automatic Defender exceptions or changes. +Unsigned publisher per accepted waiver. Issue #6 security reporter pending. diff --git a/docs/releases/v1.7.7/RELEASE_NOTES.md b/docs/releases/v1.7.7/RELEASE_NOTES.md new file mode 100644 index 00000000..6de52a4d --- /dev/null +++ b/docs/releases/v1.7.7/RELEASE_NOTES.md @@ -0,0 +1,17 @@ +# Taskbar Monitor Enhanced v1.7.7 — interrupted Setup recovery + +## Reliability fix +- The v1.7.6 Setup could leave an incomplete updater-owned `.tbme-upgrade/old/.restore` scratch if its own process was terminated mid-rollback. A later update attempt failed with `IOException: file already exists`. +- This build removes **only** known temporary `.restore` scratch files, after the journal owner marker and index have been validated; it then recreates them from the SHA-verified prior managed snapshots. +- Unknown, reparse-point, user-owned, or unrelated files are never erased. Existing rollback guards and preserved user settings remain intact. + +## Compatible upgrades +- Same per-user root; SHA-pinned official v1.7.6 predecessor is supported for transactional in-place upgrade. One versioned installer, no duplicate application root. +- Glass with true zero background alpha, red hot CPU/GPU/disk warnings, thresholds, chart readability, themes, and settings are retained. +- Optional signed external PawnIO sensor service remains separate. No new kernel driver is bundled. + +## Trust and qualification +- Setup is intentionally unsigned under the existing explicit release waiver. A valid hash/CI is not an independent malware certification. +- Reporter-specific WinRing0 driver attribution in Issue #6 remains open. +- Physical AC-interruption testing is out of the accepted scope and has not been performed. +- This release is a **candidate only** until exact-source build, native upgrade, regression, and public asset gates pass. diff --git a/docs/releases/v1.7.8/INSTALLER_README.txt b/docs/releases/v1.7.8/INSTALLER_README.txt new file mode 100644 index 00000000..e969e70d --- /dev/null +++ b/docs/releases/v1.7.8/INSTALLER_README.txt @@ -0,0 +1,10 @@ +Taskbar Monitor Enhanced v1.7.8 +One versioned unsigned per-user Setup. Safe same-root upgrade from the pinned +immutable official v1.7.7. Adds an optional Windows scheme-based warning sound +for CPU/GPU/disk temperature threshold notifications; silent by default. +To enable: Settings > Alerts > Play Windows warning sound. Use Test warning +sound there to preview it safely without heating your computer. +The sound follows Windows user sound scheme and output volume; a muted +Windows endpoint can suppress it. The configured cooldown, hysteresis and +quiet hours continue to apply. Glass zero alpha / hot red and all settings +are preserved; no extra installation or kernel driver is bundled. diff --git a/docs/releases/v1.7.8/RELEASE_NOTES.md b/docs/releases/v1.7.8/RELEASE_NOTES.md new file mode 100644 index 00000000..50a2924f --- /dev/null +++ b/docs/releases/v1.7.8/RELEASE_NOTES.md @@ -0,0 +1,35 @@ +# Taskbar Monitor Enhanced v1.7.8 — optional audible thermal warning + +## Temperature warnings + +- Adds an opt-in **Play Windows warning sound** option under Settings > Alerts. + New installations and upgrades default to **sound off**, unless you explicitly + enable it. The existing Windows temperature notification preference remains + separate and unchanged. +- When both Windows notifications and the sound option are enabled, a real + CPU/GPU/disk temperature crossing requests Windows' current Exclamation sound. + The existing per-device transition latch, 3 C hysteresis, ten-minute cooldown + and user quiet hours/snooze continue to apply: no repeated sound every poll. +- **Test warning sound** in the same Settings page previews the Windows sound + once on a deliberate click, without heating the CPU or moving thresholds. +- Uses the user's Windows sound scheme and system output volume. Sound can be + muted/disabled by Windows, so a request log is not evidence a speaker was heard. + The existing glass zero-alpha background and red hot-temperature text/charts + are unchanged. + +## Existing safe upgrade +- One intentionally unsigned Setup, same per-user installation root. + Exact SHA-pinned official v1.7.7 predecessor is supported transactionally, + including the previously verified crash recovery of updater-owned .restore. +- Preserves user config/history/autorun, GPU/disk sensor services, and all + existing themes, settings, focus and Start/fullscreen handling. +- No new driver; external signed PawnIO policy remains distinct from Setup. +- Physical AC-mains-loss testing is excluded by the owner; global machinewide + singleton and independent security signing-lab claims are not made. +- Original reporter-specific WinRing0 SHA/download origin remains pending in + GitHub Issue #6. Never disable or bypass Defender. + +## Status and evidence +- This is a private release candidate until hosted Build, GUI/sound-policy + selftest, fresh/in-place installation, native recovery, supply-chain G16 + and real owner acceptance succeed on exact source. diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 5d42e5b1..a3a967c8 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -2,17 +2,418 @@ param( [Parameter(Mandatory=$true)][ValidateSet('Install','Uninstall')][string]$Mode, [Parameter(Mandatory=$true)][string]$PayloadDir, [Parameter(Mandatory=$true)][string]$AppRoot, - [Parameter(Mandatory=$true)][string]$UserId + [Parameter(Mandatory=$true)][string]$UserId, + [string]$ExpectedBrokerSHA256='', + [string]$ExpectedSupervisorSHA256='', + [string]$ExpectedBackendZipSHA256='' ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' +# R291: privileged sensor preflight runs before ANY result/log creation. +# Ordinary per-user Setup stays unelevated; only optional sensors use UAC. +function Assert-SensorBoundaryNoReparse([string]$Path){ + if([string]::IsNullOrWhiteSpace($Path)){throw 'SENSOR_BOUNDARY_EMPTY_PATH'} + $cursor=[IO.Path]::GetFullPath($Path) + while(-not [string]::IsNullOrEmpty($cursor)){ + try{ + if(([IO.File]::GetAttributes($cursor) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw [IO.IOException]::new('SENSOR_BOUNDARY_REPARSE_REFUSED: '+$cursor) + } + }catch [IO.FileNotFoundException]{ + # Absent output may be created later; other I/O errors fail closed. + }catch [IO.DirectoryNotFoundException]{ + } + $parent=[IO.Path]::GetDirectoryName($cursor) + if([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor){break} + $cursor=$parent + } +} +function Assert-SensorPrivilegedBoundary( + [string]$RequestedMode,[string]$RequestedPayload,[string]$RequestedAppRoot,[string]$RequestedUser +){ + if($RequestedMode -cne 'Install' -and $RequestedMode -cne 'Uninstall'){ + throw 'SENSOR_BOUNDARY_INVALID_MODE' + } + if([string]::IsNullOrWhiteSpace($RequestedUser)){throw 'SENSOR_BOUNDARY_EMPTY_USER'} + $sid=if($RequestedUser -match '^S-1-5-21-(?:\d+-){3}\d+$'){ + $RequestedUser + }else{ + try{ + [string]([Security.Principal.NTAccount]::new($RequestedUser)).Translate( + [Security.Principal.SecurityIdentifier] + ).Value + }catch{ + throw 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' + } + } + if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'SENSOR_BOUNDARY_USER_PROFILE_NOT_INTERACTIVE' + } + # R305: runas with a credential prompt can launch as a different admin. + # Fail BEFORE any sensor log/result/ProgramFiles/task mutation if the + # actual elevated process identity is not the intended user's SID. + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' + } + $profileKey='HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\'+$sid + $entry=Get-ItemProperty -LiteralPath $profileKey -ErrorAction Stop + if([string]::IsNullOrWhiteSpace([string]$entry.ProfileImagePath)){ + throw 'SENSOR_BOUNDARY_PROFILE_MISSING' + } + $profile=[IO.Path]::GetFullPath( + [Environment]::ExpandEnvironmentVariables([string]$entry.ProfileImagePath) + ).TrimEnd([IO.Path]::DirectorySeparatorChar) + if($profile.StartsWith('\\') -or [IO.Path]::GetPathRoot($profile) -ceq $profile){ + throw 'SENSOR_BOUNDARY_PROFILE_NOT_LOCAL' + } + $expectedRoot=[IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local\TaskbarMonitorEnhanced')) + if(-not [IO.Path]::IsPathRooted($RequestedAppRoot) -or + -not [string]::Equals([IO.Path]::GetFullPath($RequestedAppRoot), + $expectedRoot,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' + } + if(-not [IO.Path]::IsPathRooted($RequestedPayload)){ + throw 'SENSOR_BOUNDARY_PAYLOAD_NOT_ABSOLUTE' + } + $payload=[IO.Path]::GetFullPath($RequestedPayload) + $name=[IO.Path]::GetFileName($payload.TrimEnd([IO.Path]::DirectorySeparatorChar)) + $prefix=if($RequestedMode -ceq 'Install'){'tbme_setup_'}else{'tbme_uninstall_'} + # Only direct children of this user's default Temp root are trusted. A + # profile-wide prefix would also accept Downloads or arbitrary subfolders. + # Nonstandard redirected TEMP locations fail closed (optional sensors degrade). + $expectedTempParent=[IO.Path]::GetFullPath( + (Join-Path $profile 'AppData\Local\Temp') + ).TrimEnd([IO.Path]::DirectorySeparatorChar) + if(-not [string]::Equals([IO.Path]::GetDirectoryName($payload), + $expectedTempParent,[StringComparison]::OrdinalIgnoreCase) -or + $name -cnotmatch ('^'+$prefix+'[0-9a-f]{32}$')){ + throw 'SENSOR_BOUNDARY_PAYLOAD_OUTSIDE_USER_TEMP' + } + $trustedProgramFiles=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) + if(-not [string]::Equals([IO.Path]::GetFullPath($trustedProgramFiles), + [IO.Path]::GetFullPath($env:ProgramFiles),[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_BOUNDARY_PROGRAMFILES_REDIRECT' + } + $brokerRoot=Join-Path $trustedProgramFiles 'TaskbarMonitorEnhanced\SensorBroker' + $candidates=@( + $profile,$expectedRoot,$payload,$brokerRoot, + (Join-Path $expectedRoot 'Logs'), + (Join-Path $expectedRoot 'Logs\sensor_install.log'), + (Join-Path $expectedRoot 'Logs\sensor_install_result.json'), + (Join-Path $expectedRoot 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'), + (Join-Path $expectedRoot 'cpu_temp_broker.json'), + (Join-Path $expectedRoot 'gpu_temp_broker.json'), + (Join-Path $expectedRoot 'storage_temp_broker.json'), + (Join-Path $expectedRoot 'sensor_supervisor_state.json'), + (Join-Path $payload 'TBME_Setup_Elevated_Helper.ps1'), + (Join-Path $payload 'TaskbarMonitorSensorBroker.exe'), + (Join-Path $payload 'TaskbarMonitorSensorSupervisor.exe'), + (Join-Path $brokerRoot 'TaskbarMonitorSensorBroker.exe'), + (Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe') + ) + foreach($path in $candidates){Assert-SensorBoundaryNoReparse $path} + $script:ValidatedSensorProfileRoot=$profile + return $sid +} +$validatedSensorSid=Assert-SensorPrivilegedBoundary $Mode $PayloadDir $AppRoot $UserId + +# R335: native in-memory handle guards pin canonical per-user profile +# ancestry and exact file handles across log/result writes. Refuse reparse +# points by handle, never create a user-writable script or change UAC policy. +Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public sealed class TBMEScopedUserLogLease : IDisposable { +const uint FILE_LIST_DIRECTORY=0x0001; +const uint GENERIC_READ=0x80000000; +const uint GENERIC_WRITE=0x40000000; +const uint FILE_SHARE_READ=1,FILE_SHARE_WRITE=2; +const uint OPEN_EXISTING=3, OPEN_ALWAYS=4; +const uint FILE_FLAG_BACKUP_SEMANTICS=0x02000000; +const uint FILE_FLAG_OPEN_REPARSE_POINT=0x00200000; +const uint FILE_ATTRIBUTE_DIRECTORY=0x10; +const uint FILE_ATTRIBUTE_REPARSE_POINT=0x400; +[DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] +static extern SafeFileHandle OpenNative(string name,uint access,uint share,IntPtr sec,uint disposition,uint flags,IntPtr template); +[StructLayout(LayoutKind.Sequential)] +public struct TagInfo { public uint FileAttributes; public uint ReparseTag; } +[DllImport("kernel32.dll",SetLastError=true)] +static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind,out TagInfo data,uint size); +readonly SafeFileHandle[] guard=new SafeFileHandle[5]; +int guardCount=0; +FileStream stream; +bool disposed; +static SafeFileHandle OpenChecked(string path,uint access,uint share,uint disposition,uint flags,bool isDirectory){ +SafeFileHandle handle=OpenNative(path,access,share,IntPtr.Zero,disposition,flags,IntPtr.Zero); +if(handle.IsInvalid){ +int e=Marshal.GetLastWin32Error(); +handle.Dispose(); +throw new IOException("SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_"+e+" "+Path.GetFileName(path)); +} +TagInfo info; +if(!GetFileInformationByHandleEx(handle,9,out info,(uint)Marshal.SizeOf(typeof(TagInfo)))){ +int e=Marshal.GetLastWin32Error(); +handle.Dispose(); +throw new IOException("SENSOR_LOG_HANDLE_ATTRIBUTE_TAG_QUERY_FAILED_WIN32_"+e); +} +if((info.FileAttributes&FILE_ATTRIBUTE_REPARSE_POINT)!=0 || +((info.FileAttributes&FILE_ATTRIBUTE_DIRECTORY)!=0)!=isDirectory){ +handle.Dispose(); +throw new IOException("SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED"); +} +return handle; +} +public TBMEScopedUserLogLease(string profile,string appRoot,string logDir,string exactFile){ +if(String.IsNullOrWhiteSpace(profile) || String.IsNullOrWhiteSpace(appRoot) || +String.IsNullOrWhiteSpace(logDir) || String.IsNullOrWhiteSpace(exactFile)) +throw new IOException("SENSOR_LOG_HANDLE_EMPTY_LOG_BOUNDARY"); +string p=Path.GetFullPath(profile); +string appdata=Path.Combine(p,"AppData"); +string local=Path.Combine(appdata,"Local"); +string expected=Path.GetFullPath(Path.Combine(local,"TaskbarMonitorEnhanced")); +string logs=Path.GetFullPath(Path.Combine(expected,"Logs")); +string file=Path.GetFullPath(exactFile); +string log=Path.GetFullPath(Path.Combine(logs,"sensor_install.log")); +string result=Path.GetFullPath(Path.Combine(logs,"sensor_install_result.json")); +if(!String.Equals(Path.GetFullPath(appRoot),expected,StringComparison.OrdinalIgnoreCase) || +!String.Equals(Path.GetFullPath(logDir),logs,StringComparison.OrdinalIgnoreCase) || +!(String.Equals(file,log,StringComparison.OrdinalIgnoreCase) || +String.Equals(file,result,StringComparison.OrdinalIgnoreCase))){ +throw new IOException("SENSOR_LOG_HANDLE_CANONICAL_LOG_PATH_MISMATCH"); +} +try{ +foreach(string dir in new string[]{p,appdata,local,expected,logs}){ +SafeFileHandle parent=OpenChecked(dir,FILE_LIST_DIRECTORY,FILE_SHARE_READ|FILE_SHARE_WRITE, +OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,true); +guard[guardCount++]=parent; +} +SafeFileHandle h=OpenChecked(file,GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ, +OPEN_ALWAYS,FILE_FLAG_OPEN_REPARSE_POINT,false); +try{ +stream=new FileStream(h,FileAccess.ReadWrite); +stream.Seek(0,SeekOrigin.End); +}catch{h.Dispose();throw;} +}catch{Dispose();throw;} +} +public void Append(string text){ +if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); +if(stream.Length==0){ +byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); +stream.Write(bom,0,bom.Length); +} +byte[] data=System.Text.Encoding.UTF8.GetBytes(text); +stream.Write(data,0,data.Length); +stream.Flush(true); +} +public void Overwrite(string text){ +if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); +stream.SetLength(0);stream.Position=0; +byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); +stream.Write(bom,0,bom.Length); +byte[] data=System.Text.Encoding.UTF8.GetBytes(text); +stream.Write(data,0,data.Length); +stream.Flush(true); +} +public void Dispose(){ +if(disposed)return; +disposed=true; +if(stream!=null){stream.Dispose();stream=null;} +for(int i=guardCount-1;i>=0;i--)if(guard[i]!=null)guard[i].Dispose(); +guardCount=0; +} +} +public sealed class TBMEProtectedBrokerLease : IDisposable { +const uint FILE_LIST_DIRECTORY=0x00000001; +const uint SHARE_READ=1, SHARE_WRITE=2, OPEN_EXISTING=3; +const uint BACKUP_SEMANTICS=0x02000000, OPEN_REPARSE_POINT=0x00200000; +const uint ATTR_DIRECTORY=0x10, ATTR_REPARSE=0x400; +[DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] +static extern SafeFileHandle OpenNative(string path,uint access,uint share,IntPtr sec, +uint creation,uint flags,IntPtr template); +[StructLayout(LayoutKind.Sequential)] +struct FILE_ATTRIBUTE_TAG_INFO { public uint Attributes; public uint Tag; } +[DllImport("kernel32.dll",SetLastError=true)] +static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind, +out FILE_ATTRIBUTE_TAG_INFO data,uint size); +readonly SafeFileHandle[] parents=new SafeFileHandle[3]; +int count; +bool disposed; +static SafeFileHandle OpenExactDir(string path) { +SafeFileHandle h=OpenNative(path,FILE_LIST_DIRECTORY, +SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING, +BACKUP_SEMANTICS|OPEN_REPARSE_POINT,IntPtr.Zero); +if(h.IsInvalid) { +int error=Marshal.GetLastWin32Error(); +h.Dispose(); +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_OPEN_FAILED_WIN32_"+error); +} +FILE_ATTRIBUTE_TAG_INFO info; +if(!GetFileInformationByHandleEx(h,9,out info, +(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO)))) { +int error=Marshal.GetLastWin32Error(); h.Dispose(); +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_TAG_FAILED_WIN32_"+error); +} +if((info.Attributes&ATTR_DIRECTORY)==0||(info.Attributes&ATTR_REPARSE)!=0) { +h.Dispose(); +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_REPARSE_REFUSED"); +} +return h; +} +public TBMEProtectedBrokerLease(string liveBroker) { +string program=Path.GetFullPath(Environment.GetFolderPath( +Environment.SpecialFolder.ProgramFiles)).TrimEnd(Path.DirectorySeparatorChar); +string root=Path.Combine(program,"TaskbarMonitorEnhanced"); +string broker=Path.Combine(root,"SensorBroker"); +if(String.IsNullOrWhiteSpace(liveBroker) || +!String.Equals(Path.GetFullPath(liveBroker).TrimEnd(Path.DirectorySeparatorChar), +broker,StringComparison.OrdinalIgnoreCase)) { +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_NOT_EXACT_SYSTEM_PROGRAMFILES"); +} +try { +parents[count++]=OpenExactDir(program); +parents[count++]=OpenExactDir(root); +if(!Directory.Exists(broker))Directory.CreateDirectory(broker); +parents[count++]=OpenExactDir(broker); +canonicalBroker=broker; +}catch {Dispose();throw;} +} +const uint GENERIC_READ=0x80000000, GENERIC_WRITE=0x40000000; +const uint OPEN_ALWAYS=4, ATTR_REPARSE_POINT=0x400; +[StructLayout(LayoutKind.Sequential)] +struct FILE_TIME_PAIR { public uint Low; public uint High; } +[StructLayout(LayoutKind.Sequential)] +struct FILE_HANDLE_INFO { +public uint FileAttributes; +public FILE_TIME_PAIR Created, Accessed, Modified; +public uint VolumeSerial, SizeHigh, SizeLow, NumberOfLinks, IndexHigh, IndexLow; +} +[DllImport("kernel32.dll",SetLastError=true)] +static extern bool GetFileInformationByHandle(SafeFileHandle h,out FILE_HANDLE_INFO info); +[DllImport("kernel32.dll",EntryPoint="GetFinalPathNameByHandleW", +SetLastError=true,CharSet=CharSet.Unicode)] +static extern uint FinalNative(SafeFileHandle h,System.Text.StringBuilder path, +uint capacity,uint flags); +readonly System.Collections.Generic.Dictionary leaves= +new System.Collections.Generic.Dictionary( +StringComparer.OrdinalIgnoreCase); +readonly System.Collections.Generic.List childDirectories= +new System.Collections.Generic.List(); +string canonicalBroker; +public FileStream OpenReplayLeaf(string relative) { +if(!IsPinned||String.IsNullOrWhiteSpace(relative)){ +throw new IOException("SENSOR_REPLAY_LEAF_LEASE_NOT_PINNED"); +} +relative=relative.Replace('/',Path.DirectorySeparatorChar); +string[] parts=relative.Split(Path.DirectorySeparatorChar); +if(parts.Length==0||parts.Length>8){ +throw new IOException("SENSOR_REPLAY_LEAF_TOO_DEEP"); +} +foreach(string part in parts) { +if(part=="."||part==".."|| +!System.Text.RegularExpressions.Regex.IsMatch(part,@"^[A-Za-z0-9_.-]+$")){ +throw new IOException("SENSOR_REPLAY_LEAF_BAD_COMPONENT"); +} +} +string path=Path.GetFullPath(Path.Combine(canonicalBroker,relative)); +if(!path.StartsWith(canonicalBroker+Path.DirectorySeparatorChar, +StringComparison.OrdinalIgnoreCase)){ +throw new IOException("SENSOR_REPLAY_LEAF_OUTSIDE_FIXED_BROKER"); +} +FileStream already; +if(leaves.TryGetValue(relative,out already)){return already;} +string parent=canonicalBroker; +for(int i=0;i=buffer.Capacity|| +!String.Equals(buffer.ToString(),"\\\\?\\"+path, +StringComparison.OrdinalIgnoreCase)){ +throw new IOException("SENSOR_REPLAY_LEAF_FINAL_PATH_IDENTITY_MISMATCH"); +} +FileStream fs=new FileStream(h,FileAccess.ReadWrite); +leaves.Add(relative,fs); +return fs; +}catch{h.Dispose();throw;} +} +public byte[] ReadReplayLeaf(string relative) { +FileStream fs=OpenReplayLeaf(relative); +if(fs.Length>16777216){throw new IOException("SENSOR_REPLAY_LEAF_SIZE_LIMIT");} +byte[] result=new byte[(int)fs.Length]; +fs.Position=0; +int pos=0; +while(pos16777216){ +throw new IOException("SENSOR_REPLAY_LEAF_DATA_BOUND"); +} +FileStream fs=OpenReplayLeaf(relative); +fs.Position=0; +fs.SetLength(oldBytes.Length); +fs.Write(oldBytes,0,oldBytes.Length); +fs.Flush(true); +return ReadReplayLeaf(relative); +} +public bool IsPinned {get{return !disposed&&count==3;}} +public void Dispose() { +if(disposed)return; +disposed=true; +foreach(FileStream fs in leaves.Values)fs.Dispose(); +leaves.Clear(); +for(int i=childDirectories.Count-1;i>=0;i--)childDirectories[i].Dispose(); +childDirectories.Clear(); +for(int i=count-1;i>=0;i--)if(parents[i]!=null)parents[i].Dispose(); +count=0; +} +} +'@ + $TaskName='TaskbarMonitorEnhanced Sensor Broker' $BrokerRoot=Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker' +$SensorOwnerMarker=Join-Path $BrokerRoot '.tbme-owner.sid' +$script:SensorJournalRoot=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'TaskbarMonitorEnhanced\.tbme-sensor-rollback' $LogsRoot=Join-Path $AppRoot 'Logs' $ResultPath=Join-Path $LogsRoot 'sensor_install_result.json' $LogPath=Join-Path $LogsRoot 'sensor_install.log' +# R328: the user owns this tree and can replace its directories between +# the original privileged preflight and its later log/result writes. +# Recheck immediately around creation; this does not close atomic races. +Assert-SensorBoundaryNoReparse $LogsRoot New-Item -ItemType Directory -Force -Path $LogsRoot | Out-Null +Assert-SensorBoundaryNoReparse $LogsRoot # R21 health channels are initialized before any early-return/uninstall branch so # Write-Result can always emit a stable schema under StrictMode. @@ -21,17 +422,50 @@ $CpuTransportHealthy=$false $GpuTransportHealthy=$false $StorageTransportHealthy=$false $CpuDataAvailable=$false -$RollbackPerformed=$false -$RollbackSucceeded=$false -$PreviousSensorLayerPresent=$false -$ActiveArchitecture='R21_PROCESS_ISOLATED' -$RollbackRoot=Join-Path $PayloadDir '_sensor_rollback' -$RollbackBrokerRoot=Join-Path $RollbackRoot 'SensorBroker' -$RollbackTaskXml=Join-Path $RollbackRoot 'task.xml' +# R344-F06: Setup executes the embedded Helper inside a nested scriptblock. +# Later functions write $script: state; initialize those same variables in +# script scope too, otherwise local bootstrap fields shadow the snapshots. +$script:RollbackPerformed=$false +$script:RollbackSucceeded=$false +$script:PreviousSensorLayerPresent=$false +$script:ActiveArchitecture='R21_PROCESS_ISOLATED' +$script:RollbackFiles=@() +$script:RollbackTaskXmlText=$null +$script:RollbackCaptured=$false + +# Fail closed when a user-writable profile log path was replaced after the +# initial elevated token/boundary verification. Write-time inspection is +# not an atomic handle-bound proof against concurrent rename/symlink swaps. +function Assert-SensorUserLogSink([string]$Target){ + if([string]::IsNullOrWhiteSpace($Target) -or + [string]::IsNullOrWhiteSpace($AppRoot)){ + throw 'SENSOR_LOG_BOUNDARY_MISSING' + } + $expectedRoot=[IO.Path]::GetFullPath((Join-Path $AppRoot 'Logs')).TrimEnd('\') + $actualRoot=[IO.Path]::GetFullPath($LogsRoot).TrimEnd('\') + if(-not [string]::Equals($expectedRoot,$actualRoot,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_LOG_BOUNDARY_CHANGED_ROOT' + } + $actual=[IO.Path]::GetFullPath($Target) + $log=[IO.Path]::GetFullPath((Join-Path $expectedRoot 'sensor_install.log')) + $result=[IO.Path]::GetFullPath((Join-Path $expectedRoot 'sensor_install_result.json')) + if(-not([string]::Equals($actual,$log,[StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($actual,$result,[StringComparison]::OrdinalIgnoreCase))){ + throw 'SENSOR_LOG_BOUNDARY_CHANGED_TARGET' + } + Assert-SensorBoundaryNoReparse $AppRoot + Assert-SensorBoundaryNoReparse $actualRoot + Assert-SensorBoundaryNoReparse $actual + if(-not [IO.Directory]::Exists($actualRoot)){ + throw 'SENSOR_LOG_BOUNDARY_MISSING_LOG_DIRECTORY' + } +} function Write-Log([string]$Message){ - $line=(Get-Date).ToString('o')+' '+$Message - $line | Add-Content -LiteralPath $LogPath -Encoding UTF8 + Assert-SensorUserLogSink $LogPath + $line=(Get-Date).ToString('o')+' '+$Message+[Environment]::NewLine + $lease=[TBMEScopedUserLogLease]::new($script:ValidatedSensorProfileRoot,$AppRoot,$LogsRoot,$LogPath) + try{$lease.Append($line)}finally{$lease.Dispose()} } function Write-Result( [string]$Status, @@ -42,7 +476,8 @@ function Write-Result( [bool]$TaskInstalled, [bool]$SensorHealthy ){ - [ordered]@{ + Assert-SensorUserLogSink $ResultPath + $json=[ordered]@{ Status=$Status Message=$Message PawnIOStatus=$PawnStatus @@ -61,7 +496,9 @@ function Write-Result( PreviousSensorLayerPresent=$PreviousSensorLayerPresent Time=(Get-Date).ToString('o') LogPath=$LogPath - } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + } | ConvertTo-Json -Depth 5 + $lease=[TBMEScopedUserLogLease]::new($script:ValidatedSensorProfileRoot,$AppRoot,$LogsRoot,$ResultPath) + try{$lease.Overwrite($json+[Environment]::NewLine)}finally{$lease.Dispose()} } function Get-PawnIOVersion { foreach($key in @( @@ -78,25 +515,1026 @@ function Get-PawnIOVersion { return '' } +# R296: no privileged rollback snapshot is staged to user-writable Temp. +function Get-SensorRollbackFiles([string]$Folder){ + Assert-SensorBoundaryNoReparse $Folder + $root=[IO.Path]::GetFullPath($Folder).TrimEnd('\') + $prefix=$root+'\' + $items=@(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop) + foreach($item in $items){Assert-SensorBoundaryNoReparse $item.FullName} + $files=@($items|Where-Object {-not $_.PSIsContainer}) + if($files.Count -gt 128){throw 'ROLLBACK_FILE_COUNT'} + $saved=[Collections.Generic.List[object]]::new() + [long]$total=0 + foreach($file in $files){ + $path=[IO.Path]::GetFullPath($file.FullName) + if(-not $path.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){throw 'ROLLBACK_PATH_ESCAPE'} + Assert-SensorBoundaryNoReparse $path + $rel=$path.Substring($prefix.Length) + foreach($part in $rel.Split('\')){ + if($part -ceq '.' -or $part -ceq '..' -or $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'ROLLBACK_BAD_NAME' + } + } + $fs=[IO.FileStream]::new($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + if($fs.Length -gt 16777216){throw 'ROLLBACK_FILE_TOO_LARGE'} + $total+=$fs.Length + if($total -gt 67108864){throw 'ROLLBACK_TOTAL_TOO_LARGE'} + $bytes=[byte[]]::new([int]$fs.Length) + $n=0 + while($n -lt $bytes.Length){ + $k=$fs.Read($bytes,$n,$bytes.Length-$n) + if($k -lt 1){throw 'ROLLBACK_SHORT_READ'} + $n+=$k + } + $saved.Add([pscustomobject]@{Rel=$rel;Data=$bytes}) + }finally{$fs.Dispose()} + } + return ,$saved.ToArray() +} +function Restore-SensorRollbackFiles([string]$Folder,[object[]]$Saved){ + Assert-SensorBoundaryNoReparse $Folder + New-Item -ItemType Directory -Force -Path $Folder|Out-Null + foreach($entry in $Saved){ + foreach($part in ([string]$entry.Rel).Split('\')){ + if($part -ceq '.' -or $part -ceq '..' -or $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'ROLLBACK_RESTORE_BAD_NAME' + } + } + $path=Join-Path $Folder $entry.Rel + Assert-SensorBoundaryNoReparse $path + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path)|Out-Null + Assert-SensorBoundaryNoReparse $path + [IO.File]::WriteAllBytes($path,[byte[]]$entry.Data) + } +} +function Clear-SensorRollbackBuffer { + $script:RollbackFiles=@() + $script:RollbackTaskXmlText=$null + $script:RollbackCaptured=$false +} + +# R308 experimental protected write-ahead snapshot. The current review stage +# deliberately refuses preexisting/stale journals rather than consuming them. +# Crash recovery and terminal cleanup need separate native acceptance. +function Get-SensorJournalHash([byte[]]$Value){ + $sha=[Security.Cryptography.SHA256]::Create() + try{return [BitConverter]::ToString($sha.ComputeHash($Value)).Replace('-','')} + finally{$sha.Dispose()} +} +function Assert-SensorJournalRelative([string]$Rel){ + if([string]::IsNullOrWhiteSpace($Rel) -or $Rel.Length -gt 240 -or + [IO.Path]::IsPathRooted($Rel) -or $Rel.Contains('/') -or + $Rel.Contains(':') -or $Rel.StartsWith('\') -or $Rel.EndsWith('\')){ + throw 'SENSOR_JOURNAL_UNSAFE_RELATIVE_NAME' + } + foreach($part in $Rel.Split('\')){ + if($part -ceq '.' -or $part -ceq '..' -or $part.EndsWith('.') -or + $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'SENSOR_JOURNAL_UNSAFE_RELATIVE_NAME' + } + } +} +function Assert-SensorJournalTrustedAcl([string]$Path,[bool]$RequireProtected){ + Assert-SensorBoundaryNoReparse $Path + $acl=Get-Acl -LiteralPath $Path -ErrorAction Stop + $owner=([Security.Principal.NTAccount]::new([string]$acl.Owner)).Translate( + [Security.Principal.SecurityIdentifier]).Value + $trusted=@('S-1-5-18','S-1-5-32-544', + 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') + if($trusted -cnotcontains $owner){throw 'SENSOR_JOURNAL_UNTRUSTED_OWNER'} + if($RequireProtected -and -not $acl.AreAccessRulesProtected){ + throw 'SENSOR_JOURNAL_UNPROTECTED_DACL' + } + # R344-F03: FileSystemRights.Modify includes ReadAndExecute. + # Bitwise testing the composite Modify value wrongly treated benign + # BUILTIN Users ReadAndExecute as a write ACE. Check only mutation + # capabilities; never loosen the trusted owner or SID allowlist. + $write=[Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + foreach($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){ + if($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow){continue} + if($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly){continue} + if(($trusted -cnotcontains [string]$rule.IdentityReference.Value) -and + ($rule.FileSystemRights -band $write)){ + throw 'SENSOR_JOURNAL_UNTRUSTED_WRITE_ACE' + } + } +} +function Write-SensorJournalUnique([string]$Root,[string]$Relative,[byte[]]$Bytes){ + Assert-SensorJournalRelative $Relative + $file=Join-Path $Root $Relative + $parent=[IO.Path]::GetDirectoryName($file) + Assert-SensorBoundaryNoReparse $parent + [void][IO.Directory]::CreateDirectory($parent) + Assert-SensorJournalTrustedAcl $parent $false + Assert-SensorBoundaryNoReparse $file + $fs=[IO.FileStream]::new($file,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$fs.Write($Bytes,0,$Bytes.Length);$fs.Flush($true)} + finally{$fs.Dispose()} + Assert-SensorJournalTrustedAcl $file $false +} +function Assert-SensorDurableJournal([string]$Root,[string]$ExpectedSid){ + Assert-SensorJournalTrustedAcl $Root $true + $ready=Join-Path $Root 'READY' + Assert-SensorBoundaryNoReparse $ready + if(-not [IO.File]::Exists($ready) -or + [IO.File]::ReadAllText($ready,[Text.Encoding]::ASCII) -cne 'TBME_SENSOR_READY_V1'){ + throw 'SENSOR_JOURNAL_INCOMPLETE_OR_UNREADY' + } + foreach($name in @('READY','manifest.json','task.xml')){ + Assert-SensorJournalTrustedAcl (Join-Path $Root $name) $false + } + $file=Join-Path $Root 'manifest.json' + if(([IO.FileInfo]::new($file)).Length -gt 65536){throw 'SENSOR_JOURNAL_MANIFEST_OVERSIZE'} + $manifest=[IO.File]::ReadAllText($file,[Text.Encoding]::UTF8)|ConvertFrom-Json + if($manifest.Schema -ne 1 -or $manifest.Phase -cne 'PREPARED' -or + $manifest.Sid -cne $ExpectedSid){throw 'SENSOR_JOURNAL_OWNER_OR_PHASE_MISMATCH'} + $bytes=[IO.File]::ReadAllBytes((Join-Path $Root 'task.xml')) + if($bytes.Length -gt 262144 -or + (Get-SensorJournalHash $bytes) -cne [string]$manifest.TaskHash){ + throw 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' + } + $allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($n in @('READY','manifest.json','task.xml')){[void]$allowed.Add((Join-Path $Root $n))} + [void]$allowed.Add((Join-Path $Root 'files')) + # R309 terminal markers are known members, but only a valid bounded + # marker and exact protected ownership may be accepted. + foreach($phase in @('COMMITTED','ROLLED_BACK')){ + $p=Join-Path $Root ($phase+'.terminal') + if([IO.File]::Exists($p)){ + Assert-SensorJournalTrustedAcl $p $false + if([IO.File]::ReadAllText($p,[Text.Encoding]::ASCII) -cne + ('TBME_SENSOR_'+$phase+'_V1')){ + throw 'SENSOR_JOURNAL_UNKNOWN_TERMINAL_MARKER' + } + [void]$allowed.Add($p) + } + } + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + if(@($manifest.Files).Count -gt 128){throw 'SENSOR_JOURNAL_SNAPSHOT_COUNT_INVALID'} + [long]$total=0 + foreach($row in @($manifest.Files)){ + $rel=[string]$row.Rel + Assert-SensorJournalRelative $rel + if(-not $seen.Add($rel)){throw 'SENSOR_JOURNAL_DUPLICATE_SNAPSHOT'} + $path=Join-Path (Join-Path $Root 'files') $rel + Assert-SensorJournalTrustedAcl $path $false + $blob=[IO.File]::ReadAllBytes($path) + $total+=$blob.Length + if($blob.Length -gt 16777216 -or $total -gt 67108864 -or + $blob.Length -ne [long]$row.Length -or + (Get-SensorJournalHash $blob) -cne [string]$row.Hash){ + throw 'SENSOR_JOURNAL_SNAPSHOT_HASH_MISMATCH' + } + [void]$allowed.Add($path) + $parent=[IO.Path]::GetDirectoryName($path) + while($parent.StartsWith((Join-Path $Root 'files')+'\',[StringComparison]::OrdinalIgnoreCase)){ + [void]$allowed.Add($parent) + $parent=[IO.Path]::GetDirectoryName($parent) + } + } + if(-not [IO.Directory]::Exists((Join-Path $Root 'files'))){ + throw 'SENSOR_JOURNAL_FILES_DIRECTORY_MISSING' + } + # Check each child before recursing; never follow a junction while merely + # enumerating the rollback journal (PowerShell provider behavior varies). + $pending=[Collections.Generic.List[string]]::new() + $pending.Add($Root) + while($pending.Count -gt 0){ + $directory=$pending[$pending.Count-1] + $pending.RemoveAt($pending.Count-1) + Assert-SensorBoundaryNoReparse $directory + foreach($member in [IO.Directory]::GetFileSystemEntries($directory)){ + $full=[IO.Path]::GetFullPath($member) + $attrs=[IO.File]::GetAttributes($full) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_JOURNAL_REPARSE_MEMBER' + } + if(-not $allowed.Contains($full)){ + throw 'SENSOR_JOURNAL_UNKNOWN_MEMBER' + } + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + $pending.Add($full) + } + } + } + return $manifest +} +function New-SensorDurableJournal( + [string]$Root,[string]$Sid,[object[]]$Rows,[string]$TaskXml, + [string[]]$NewNames,[bool]$OldRootPresent +){ + $token=[Security.Principal.WindowsIdentity]::GetCurrent().User + if($null -eq $token -or $token.Value -cne $Sid){ + throw 'SENSOR_JOURNAL_TOKEN_MISMATCH' + } + Assert-SensorBoundaryNoReparse $Root + if(Test-Path -LiteralPath $Root){throw 'SENSOR_JOURNAL_ALREADY_EXISTS'} + $parent=[IO.Path]::GetDirectoryName($Root) + Assert-SensorJournalTrustedAcl $parent $false + $all=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + if(@($Rows).Count -gt 128 -or @($NewNames).Count -gt 64){ + throw 'SENSOR_JOURNAL_INVENTORY_TOO_LARGE' + } + $index=[Collections.Generic.List[object]]::new() + [long]$sum=0 + foreach($r in @($Rows)){ + $rel=[string]$r.Rel + Assert-SensorJournalRelative $rel + if(-not $all.Add($rel)){throw 'SENSOR_JOURNAL_DUPLICATE_ORIGINAL'} + $payload=[byte[]]$r.Data + $sum+=$payload.Length + if($payload.Length -gt 16777216 -or $sum -gt 67108864){ + throw 'SENSOR_JOURNAL_TOO_LARGE' + } + $index.Add([pscustomobject]@{Rel=$rel;Length=$payload.Length;Hash=(Get-SensorJournalHash $payload)}) + } + $newList=[Collections.Generic.List[string]]::new() + $newSeen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($name in $NewNames){ + Assert-SensorJournalRelative $name + if(-not $newSeen.Add($name)){throw 'SENSOR_JOURNAL_DUPLICATE_NEXT_FILE'} + $newList.Add($name) + } + $xml=[Text.Encoding]::UTF8.GetBytes([string]$TaskXml) + if($xml.Length -gt 262144){throw 'SENSOR_JOURNAL_XML_TOO_LARGE'} + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins) + $acl.SetAccessRuleProtection($true,$false) + foreach($principal in @($admins,$system)){ + $ace=[Security.AccessControl.FileSystemAccessRule]::new( + $principal,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + [void]$acl.AddAccessRule($ace) + } + [void][IO.Directory]::CreateDirectory($Root,$acl) + Assert-SensorJournalTrustedAcl $Root $true + [void][IO.Directory]::CreateDirectory((Join-Path $Root 'files')) + Assert-SensorJournalTrustedAcl (Join-Path $Root 'files') $false + foreach($r in @($Rows)){ + Write-SensorJournalUnique $Root ('files\'+[string]$r.Rel) ([byte[]]$r.Data) + } + Write-SensorJournalUnique $Root 'task.xml' $xml + $manifest=[ordered]@{ + Schema=1;Phase='PREPARED';Sid=$Sid;OldRootPresent=$OldRootPresent + TaskPresent=($xml.Length -gt 0);TaskHash=(Get-SensorJournalHash $xml) + Files=@($index);ExpectedNewFiles=@($newList) + } + $json=[Text.Encoding]::UTF8.GetBytes(($manifest|ConvertTo-Json -Depth 6 -Compress)) + if($json.Length -gt 65536){throw 'SENSOR_JOURNAL_MANIFEST_TOO_LARGE'} + Write-SensorJournalUnique $Root 'manifest.json' $json + # READY is written and flushed LAST, before any service/task mutation. + Write-SensorJournalUnique $Root 'READY' ([Text.Encoding]::ASCII.GetBytes('TBME_SENSOR_READY_V1')) + $verified=Assert-SensorDurableJournal $Root $Sid + if($verified.Phase -cne 'PREPARED'){throw 'SENSOR_JOURNAL_READY_READBACK_FAILED'} +} + +# R309: terminal state and byte-exact file replay are designed as replayable, +# bounded operations. Scheduled Task XML restoration/actual protected UAC +# recovery are separate acceptance gates; this file-only primitive must +# never be interpreted as proof of task/service recovery. +function Get-SensorJournalTerminal([string]$Root,[string]$Sid){ + # R309: bind all phase/replay calls to the actual effective Windows + # token, not merely to a caller-provided or journal-stored SID. + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' + } + [void](Assert-SensorDurableJournal $Root $Sid) + $found='' + foreach($phase in @('COMMITTED','ROLLED_BACK')){ + $path=Join-Path $Root ($phase+'.terminal') + if(-not [IO.File]::Exists($path)){continue} + Assert-SensorJournalTrustedAcl $path $false + $expected='TBME_SENSOR_'+$phase+'_V1' + if([IO.File]::ReadAllText($path,[Text.Encoding]::ASCII) -cne $expected){ + throw 'SENSOR_JOURNAL_UNTRUSTED_TERMINAL_MARKER' + } + if($found){throw 'SENSOR_JOURNAL_CONFLICTING_TERMINAL_PHASE'} + $found=$phase + } + if(!$found){return 'PREPARED'} + return $found +} +function Write-SensorJournalTerminal([string]$Root,[string]$Sid,[string]$Phase){ + if($Phase -cne 'COMMITTED' -and $Phase -cne 'ROLLED_BACK'){ + throw 'SENSOR_JOURNAL_TERMINAL_PHASE_NOT_ALLOWED' + } + $current=Get-SensorJournalTerminal $Root $Sid + if($current -ceq $Phase){return} + if($current -cne 'PREPARED'){throw 'SENSOR_JOURNAL_TERMINAL_CONFLICT'} + $target=Join-Path $Root ($Phase+'.terminal') + $bytes=[Text.Encoding]::ASCII.GetBytes('TBME_SENSOR_'+$Phase+'_V1') + Write-SensorJournalUnique $Root ($Phase+'.terminal') $bytes + if([IO.File]::ReadAllText($target,[Text.Encoding]::ASCII) -cne + ('TBME_SENSOR_'+$Phase+'_V1')){ + throw 'SENSOR_JOURNAL_TERMINAL_DURABLE_READBACK' + } + if((Get-SensorJournalTerminal $Root $Sid) -cne $Phase){ + throw 'SENSOR_JOURNAL_TERMINAL_STATE_READBACK' + } +} +function Restore-SensorJournalOldFilesOnly( + [string]$Root,[string]$Sid,[string]$LiveRoot, + [object]$PinnedBrokerLease=$null +){ + $state=Get-SensorJournalTerminal $Root $Sid + if($state -cne 'PREPARED' -and $state -cne 'ROLLED_BACK'){ + throw 'SENSOR_JOURNAL_REPLAY_REQUIRES_PREPARED_STATE' + } + $manifest=Assert-SensorDurableJournal $Root $Sid + # Cache and verify every snapshot and prior task XML BEFORE any live + # synthetic file changes. Never reread saved bytes after validation. + $prior=[Collections.Generic.List[object]]::new() + $original=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + [long]$total=0 + foreach($row in @($manifest.Files)){ + $rel=[string]$row.Rel + Assert-SensorJournalRelative $rel + if(-not $original.Add($rel)){throw 'SENSOR_JOURNAL_DUPLICATE_ORIGINAL'} + $path=Join-Path (Join-Path $Root 'files') $rel + Assert-SensorJournalTrustedAcl $path $false + $fs=[IO.FileStream]::new($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + if($fs.Length -ne [long]$row.Length -or $fs.Length -gt 16777216){ + throw 'SENSOR_JOURNAL_REPLAY_SIZE_INVALID' + } + $total+=$fs.Length + if($total -gt 67108864){throw 'SENSOR_JOURNAL_REPLAY_TOTAL_INVALID'} + $bytes=[byte[]]::new([int]$fs.Length) + $position=0 + while($position -lt $bytes.Length){ + $got=$fs.Read($bytes,$position,$bytes.Length-$position) + if($got -lt 1){throw 'SENSOR_JOURNAL_REPLAY_SHORT_READ'} + $position+=$got + } + if((Get-SensorJournalHash $bytes) -cne [string]$row.Hash){ + throw 'SENSOR_JOURNAL_REPLAY_SHA_MISMATCH' + } + $prior.Add([pscustomobject]@{Rel=$rel;Bytes=$bytes;Sha=[string]$row.Hash}) + }finally{$fs.Dispose()} + } + $xmlPath=Join-Path $Root 'task.xml' + Assert-SensorJournalTrustedAcl $xmlPath $false + $xml=[IO.File]::ReadAllBytes($xmlPath) + if($xml.Length -gt 262144 -or + (Get-SensorJournalHash $xml) -cne [string]$manifest.TaskHash){ + throw 'SENSOR_JOURNAL_REPLAY_XML_SHA_INVALID' + } + # Validate an EXACT allowlist for the live tree before a single delete + # or overwrite. Real privileged service/task mutation is never executed + # by this primitive and must be guarded at its own ownership boundary. + $known=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($item in @($manifest.ExpectedNewFiles)){ + $rel=[string]$item + Assert-SensorJournalRelative $rel + if(-not $known.Add($rel)){throw 'SENSOR_JOURNAL_REPLAY_DUPLICATE_NEW'} + } + foreach($item in $prior){[void]$known.Add([string]$item.Rel)} + $dirs=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($rel in $known){ + $parts=$rel.Split('\') + for($k=1;$k -lt $parts.Length;$k++){ + [void]$dirs.Add(($parts[0..($k-1)] -join '\')) + } + } + Assert-SensorBoundaryNoReparse $LiveRoot + if([IO.Directory]::Exists($LiveRoot)){ + $prefix=[IO.Path]::GetFullPath($LiveRoot).TrimEnd('\')+'\' + $pending=[Collections.Generic.List[string]]::new() + $pending.Add($LiveRoot) + while($pending.Count){ + $directory=$pending[$pending.Count-1] + $pending.RemoveAt($pending.Count-1) + Assert-SensorBoundaryNoReparse $directory + foreach($member in [IO.Directory]::GetFileSystemEntries($directory)){ + $full=[IO.Path]::GetFullPath($member) + $attrs=[IO.File]::GetAttributes($full) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_JOURNAL_REPLAY_LIVE_REPARSE_REFUSED' + } + if(-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_REPLAY_LIVE_PATH_ESCAPE' + } + $rel=$full.Substring($prefix.Length) + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + if(-not $dirs.Contains($rel)){ + throw 'SENSOR_JOURNAL_REPLAY_UNKNOWN_DIRECTORY' + } + $pending.Add($full) + }elseif(-not $known.Contains($rel)){ + throw 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' + } + } + } + } + # R349: acquire native READ/WRITE, NO SHARE_WRITE/DELETE, no-reparse + # handles for every original leaf BEFORE the first rollback write. The + # lease stays open across original Task restoration and journal terminal + # cleanup in the caller. Any privileged pre-opened writer or DELETE handle + # causes a fail-closed error while PREPARED remains intact. + if($null -ne $PinnedBrokerLease){ + if(-not $PinnedBrokerLease.IsPinned){ + throw 'SENSOR_REPLAY_LEAF_BROKER_PARENT_LEASE_LOST' + } + foreach($entry in $prior){ + $null=$PinnedBrokerLease.OpenReplayLeaf([string]$entry.Rel) + } + } + # Stage validated originals back into the live synthetic tree. + # A process death mid-copy leaves READY and intact old snapshots, so a + # subsequent same-account invocation can resume. This isn't an atomic + # multi-file transaction or proof of OS power-cut durability. + if(-not [IO.Directory]::Exists($LiveRoot)){ + [void][IO.Directory]::CreateDirectory($LiveRoot) + } + foreach($entry in $prior){ + $dest=Join-Path $LiveRoot $entry.Rel + $parent=[IO.Path]::GetDirectoryName($dest) + Assert-SensorBoundaryNoReparse $dest + [void][IO.Directory]::CreateDirectory($parent) + Assert-SensorBoundaryNoReparse $dest + if($null -ne $PinnedBrokerLease){ + $verifiedBytes=[byte[]]$PinnedBrokerLease.ReplaceReplayLeaf( + [string]$entry.Rel,[byte[]]$entry.Bytes) + }else{ + [IO.File]::WriteAllBytes($dest,[byte[]]$entry.Bytes) + $verifiedBytes=[IO.File]::ReadAllBytes($dest) + } + if((Get-SensorJournalHash $verifiedBytes) -cne $entry.Sha){ + throw 'SENSOR_JOURNAL_REPLAY_WRITE_SHA_FAILED' + } + } + foreach($rel in $known){ + if($original.Contains($rel)){continue} + $dest=Join-Path $LiveRoot $rel + Assert-SensorBoundaryNoReparse $dest + if([IO.File]::Exists($dest)){[IO.File]::Delete($dest)} + } + foreach($entry in $prior){ + $dest=Join-Path $LiveRoot $entry.Rel + if($null -ne $PinnedBrokerLease){ + $bytes=[byte[]]$PinnedBrokerLease.ReadReplayLeaf([string]$entry.Rel) + }else{ + if(-not [IO.File]::Exists($dest)){ + throw 'SENSOR_JOURNAL_REPLAY_FINAL_MISSING_FILE' + } + $bytes=[IO.File]::ReadAllBytes($dest) + } + if((Get-SensorJournalHash $bytes) -cne $entry.Sha){ + throw 'SENSOR_JOURNAL_REPLAY_FINAL_SHA_FAILED' + } + } + # R337: a first protected-sensor installation has NO previous BrokerRoot. + # A killed helper may leave only newly added nested files and directories. + # After validated snapshot replay and allowlisted file deletion, remove + # ONLY now-empty known directories, deepest first, using nonrecursive + # Directory.Delete. Refuse any surprise member or reparse point. Keeping + # an empty unowned BrokerRoot would block the next same-owner repair. + if(-not [bool]$manifest.OldRootPresent){ + if($prior.Count -ne 0){ + throw 'SENSOR_JOURNAL_ABSENT_OLD_ROOT_HAS_SNAPSHOT_FILES' + } + foreach($relDir in @($dirs | Sort-Object Length -Descending)){ + $emptyDir=Join-Path $LiveRoot $relDir + Assert-SensorBoundaryNoReparse $emptyDir + if([IO.Directory]::Exists($emptyDir)){ + [IO.Directory]::Delete($emptyDir,$false) + } + } + Assert-SensorBoundaryNoReparse $LiveRoot + if([IO.Directory]::Exists($LiveRoot)){ + [IO.Directory]::Delete($LiveRoot,$false) + } + } + return [Text.Encoding]::UTF8.GetString($xml) +} + +# R310: terminal journal cleanup is a separate, bounded state machine. +# Never recursively delete or follow a reparse member. Retire the complete +# terminal journal to a fixed sibling before deleting any saved old bytes. +function Assert-SensorRetiredJournalSafe([string]$Retired,[string]$Sid){ + Assert-SensorJournalTrustedAcl $Retired $true + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals($effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_RETIRED_JOURNAL_WRONG_EFFECTIVE_TOKEN' + } + $members=@([IO.Directory]::GetFileSystemEntries($Retired)) + if($members.Count -eq 0){return $null} + $phase='' + $marker='' + foreach($p in @('COMMITTED','ROLLED_BACK')){ + $file=Join-Path $Retired ($p+'.terminal') + Assert-SensorBoundaryNoReparse $file + if(-not [IO.File]::Exists($file)){continue} + Assert-SensorJournalTrustedAcl $file $false + if([IO.File]::ReadAllText($file,[Text.Encoding]::ASCII) -cne + ('TBME_SENSOR_'+$p+'_V1')){ + throw 'SENSOR_RETIRED_JOURNAL_BAD_TERMINAL' + } + if($phase){throw 'SENSOR_RETIRED_JOURNAL_TWO_TERMINALS'} + $phase=$p + $marker=$file + } + if(!$phase){throw 'SENSOR_RETIRED_JOURNAL_NO_TERMINAL'} + $allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + [void]$allowed.Add($marker) + $manifestFile=Join-Path $Retired 'manifest.json' + $saved=@() + if([IO.File]::Exists($manifestFile)){ + Assert-SensorJournalTrustedAcl $manifestFile $false + if(([IO.FileInfo]::new($manifestFile)).Length -gt 65536){ + throw 'SENSOR_RETIRED_JOURNAL_MANIFEST_OVERSIZE' + } + $m=[IO.File]::ReadAllText($manifestFile,[Text.Encoding]::UTF8)|ConvertFrom-Json + if($m.Schema -ne 1 -or $m.Phase -cne 'PREPARED' -or + $m.Sid -cne $Sid){throw 'SENSOR_RETIRED_JOURNAL_MANIFEST_OWNER_PHASE'} + if(@($m.Files).Count -gt 128 -or @($m.ExpectedNewFiles).Count -gt 64){ + throw 'SENSOR_RETIRED_JOURNAL_INVENTORY_SIZE' + } + $known=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $paths=[Collections.Generic.List[string]]::new() + foreach($row in @($m.Files)){ + $rel=[string]$row.Rel + Assert-SensorJournalRelative $rel + if(-not $known.Add($rel)){throw 'SENSOR_RETIRED_JOURNAL_DUPLICATE_ENTRY'} + $path=Join-Path (Join-Path $Retired 'files') $rel + $paths.Add($path) + [void]$allowed.Add($path) + $dir=[IO.Path]::GetDirectoryName($path) + $prefix=(Join-Path $Retired 'files') + while($dir.StartsWith($prefix+'\', [StringComparison]::OrdinalIgnoreCase)){ + [void]$allowed.Add($dir) + $dir=[IO.Path]::GetDirectoryName($dir) + } + } + $saved=@($paths.ToArray()) + foreach($name in @('manifest.json','task.xml','READY','files')){ + [void]$allowed.Add((Join-Path $Retired $name)) + } + # Terminal phase is published only after a fully verified snapshot. + # During cleanup any subset of previously saved files may be absent, + # but an unknown added file must still prevent destructive progress. + } + # Validate every present member before deleting anything. + $stack=[Collections.Generic.List[string]]::new() + $stack.Add($Retired) + while($stack.Count){ + $dir=$stack[$stack.Count-1] + $stack.RemoveAt($stack.Count-1) + Assert-SensorBoundaryNoReparse $dir + foreach($item in [IO.Directory]::GetFileSystemEntries($dir)){ + $full=[IO.Path]::GetFullPath($item) + $attributes=[IO.File]::GetAttributes($full) + if(($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_RETIRED_JOURNAL_REPARSE_REFUSED' + } + if(-not $allowed.Contains($full)){ + throw 'SENSOR_RETIRED_JOURNAL_UNKNOWN_MEMBER' + } + Assert-SensorJournalTrustedAcl $full $false + if(($attributes -band [IO.FileAttributes]::Directory) -ne 0){ + $stack.Add($full) + } + } + } + return [pscustomobject]@{Phase=$phase;Marker=$marker;Manifest=[IO.File]::Exists($manifestFile);Saved=$saved} +} +function Finish-SensorRetiredJournal([string]$Retired,[string]$Sid){ + if(-not [IO.Directory]::Exists($Retired)){return} + $info=Assert-SensorRetiredJournalSafe $Retired $Sid + if($null -eq $info){ + [IO.Directory]::Delete($Retired,$false) + return + } + if($info.Manifest){ + foreach($path in @($info.Saved)){ + Assert-SensorBoundaryNoReparse $path + if([IO.File]::Exists($path)){ + Assert-SensorJournalTrustedAcl $path $false + [IO.File]::Delete($path) + } + } + $files=Join-Path $Retired 'files' + if([IO.Directory]::Exists($files)){ + $folders=[Collections.Generic.List[string]]::new() + $pending=[Collections.Generic.List[string]]::new() + $pending.Add($files) + while($pending.Count){ + $current=$pending[$pending.Count-1] + $pending.RemoveAt($pending.Count-1) + $folders.Add($current) + foreach($entry in [IO.Directory]::GetFileSystemEntries($current)){ + $attrs=[IO.File]::GetAttributes($entry) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_RETIRED_JOURNAL_REPARSE_REFUSED' + } + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + $pending.Add($entry) + }else{ + throw 'SENSOR_RETIRED_JOURNAL_NONEMPTY_FILES_DIR' + } + } + } + $order=@($folders|Sort-Object Length -Descending) + foreach($dir in $order){ + Assert-SensorBoundaryNoReparse $dir + [IO.Directory]::Delete($dir,$false) + } + } + foreach($name in @('task.xml','READY','manifest.json')){ + $path=Join-Path $Retired $name + Assert-SensorBoundaryNoReparse $path + if([IO.File]::Exists($path)){ + Assert-SensorJournalTrustedAcl $path $false + [IO.File]::Delete($path) + } + } + } + # Once manifest is gone ONLY the verified terminal marker may remain. + $remaining=@([IO.Directory]::GetFileSystemEntries($Retired)) + if($remaining.Count -ne 1 -or + -not [string]::Equals($remaining[0],$info.Marker,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_RETIRED_JOURNAL_CLEANUP_HAS_UNKNOWN_RESIDUE' + } + Assert-SensorJournalTrustedAcl $info.Marker $false + [IO.File]::Delete($info.Marker) + [IO.Directory]::Delete($Retired,$false) +} +function Finish-SensorTerminalJournal([string]$Root,[string]$Sid){ + $root=[IO.Path]::GetFullPath($Root) + $retired=$root+'-retired' + if(-not [string]::Equals([IO.Path]::GetDirectoryName($root), + [IO.Path]::GetDirectoryName($retired), + [StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_RETIRED_DIFFERENT_PARENT' + } + Assert-SensorBoundaryNoReparse $root + Assert-SensorBoundaryNoReparse $retired + # Always finish a previously retired journal first. If it fails, do not + # touch active journal or any protected Broker bytes. + if([IO.Directory]::Exists($retired)){ + Finish-SensorRetiredJournal $retired $Sid + } + if(-not [IO.Directory]::Exists($root)){return} + $terminal=Get-SensorJournalTerminal $root $Sid + if($terminal -ne 'COMMITTED' -and $terminal -ne 'ROLLED_BACK'){ + throw 'SENSOR_JOURNAL_NOT_TERMINAL_DO_NOT_RETIRE' + } + if([IO.Directory]::Exists($retired)){throw 'SENSOR_JOURNAL_RETIRED_COLLISION'} + [IO.Directory]::Move($root,$retired) + Finish-SensorRetiredJournal $retired $Sid +} + +# R311 integrates already-tested R309/R310 terminal machinery into the +# normal protected install success path, not into unverified task restoration. +function Resume-SensorCompletedJournal([string]$Root,[string]$Sid){ + Assert-SensorBoundaryNoReparse $Root + Assert-SensorBoundaryNoReparse ($Root+'-retired') + # R310 prioritizes finishing a prior retired state. Then an active, + # uncommitted PREPARED state MUST fail closed, never guessed complete. + Finish-SensorTerminalJournal $Root $Sid + if([IO.Directory]::Exists($Root) -or [IO.Directory]::Exists($Root+'-retired')){ + throw 'SENSOR_JOURNAL_RECOVERY_REQUIRED' + } +} +function Complete-HealthySensorJournal( + [string]$Root,[string]$Sid,[bool]$TaskInstalled,[bool]$SupervisorHealthy +){ + if(-not $TaskInstalled -or -not $SupervisorHealthy){ + throw 'SENSOR_JOURNAL_SUPERVISOR_NOT_HEALTHY_NO_COMMIT' + } + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_COMMIT_EFFECTIVE_SID_MISMATCH' + } + $phase=Get-SensorJournalTerminal $Root $Sid + if($phase -cne 'PREPARED'){ + throw 'SENSOR_JOURNAL_COMMIT_NONPREPARED_REFUSED' + } + Write-SensorJournalTerminal $Root $Sid 'COMMITTED' + # A crash after writing COMMITTED is recoverable by the entrypoint + # above; a crash during retirement is recoverable by R310's bounded + # nonrecursive retired journal cleanup. + Finish-SensorTerminalJournal $Root $Sid + if([IO.Directory]::Exists($Root) -or [IO.Directory]::Exists($Root+'-retired')){ + throw 'SENSOR_JOURNAL_COMMIT_RETIRE_RESIDUE' + } +} + +# R312: replaying a prior Scheduled Task XML can register executable actions. +# Only the exact prior same-SID LogonTrigger and pinned Supervisor Exec are +# allowed. Never permit RegistrationTrigger, alternate command or XML DTD. +function Resolve-SensorPriorTaskPrincipalSid([string]$name){ + if([string]::IsNullOrWhiteSpace($name)){throw 'SENSOR_JOURNAL_TASK_XML_EMPTY_IDENTITY'} + if($name -match '^S-1-5-(?:\d+-)+\d+$'){return $name} + try{ + return [string]([Security.Principal.NTAccount]::new($name)).Translate( + [Security.Principal.SecurityIdentifier]).Value + }catch{throw 'SENSOR_JOURNAL_TASK_XML_IDENTITY_UNRESOLVABLE'} +} +function Assert-SensorTrustedOldTaskXml( + [string]$Text,[string]$ExpectedSid,[string]$TrustedBrokerRoot,[string]$TrustedAppRoot +){ + if([string]::IsNullOrWhiteSpace($Text) -or $Text.Length -gt 262144){ + throw 'SENSOR_JOURNAL_TASK_XML_MISSING_OR_OVERSIZE' + } + if($ExpectedSid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'SENSOR_JOURNAL_TASK_XML_EXPECTED_SID_UNTRUSTED' + } + $xml=[Xml.XmlDocument]::new() + $xml.XmlResolver=$null + $settings=[Xml.XmlReaderSettings]::new() + $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit + $settings.XmlResolver=$null + $settings.MaxCharactersInDocument=262144 + $settings.MaxCharactersFromEntities=0 + $settings.IgnoreComments=$false + $sr=[IO.StringReader]::new($Text) + $reader=$null + try{ + $reader=[Xml.XmlReader]::Create($sr,$settings) + $xml.Load($reader) + }finally{ + if($reader){$reader.Dispose()} + $sr.Dispose() + } + $n=[Xml.XmlNamespaceManager]::new($xml.NameTable) + $n.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + if($null -eq $xml.DocumentElement -or + $xml.DocumentElement.LocalName -cne 'Task' -or + $xml.DocumentElement.NamespaceURI -cne 'http://schemas.microsoft.com/windows/2004/02/mit/task'){ + throw 'SENSOR_JOURNAL_TASK_XML_UNSUPPORTED_NAMESPACE' + } + $principals=@($xml.SelectNodes('/t:Task/t:Principals/t:Principal',$n)) + $actions=@($xml.SelectNodes('/t:Task/t:Actions/*',$n)) + $triggers=@($xml.SelectNodes('/t:Task/t:Triggers/*',$n)) + if($principals.Count -ne 1 -or $actions.Count -ne 1 -or + $triggers.Count -ne 1 -or $actions[0].LocalName -cne 'Exec' -or + $triggers[0].LocalName -cne 'LogonTrigger'){ + throw 'SENSOR_JOURNAL_TASK_XML_UNEXPECTED_ACTION_OR_TRIGGER' + } + $principal=$principals[0] + $uid=$principal.SelectSingleNode('t:UserId',$n) + $logon=$principal.SelectSingleNode('t:LogonType',$n) + $run=$principal.SelectSingleNode('t:RunLevel',$n) + if(!$uid -or !$logon -or !$run -or + (Resolve-SensorPriorTaskPrincipalSid ([string]$uid.InnerText)) -cne $ExpectedSid -or + [string]$logon.InnerText -cne 'InteractiveToken' -or + [string]$run.InnerText -cne 'HighestAvailable'){ + throw 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' + } + $triggerUid=$triggers[0].SelectSingleNode('t:UserId',$n) + if(!$triggerUid -or + (Resolve-SensorPriorTaskPrincipalSid ([string]$triggerUid.InnerText)) -cne $ExpectedSid){ + throw 'SENSOR_JOURNAL_TASK_XML_WRONG_LOGON_USER' + } + $exe=$actions[0] + $command=$exe.SelectSingleNode('t:Command',$n) + $args=$exe.SelectSingleNode('t:Arguments',$n) + if(!$command -or !$args -or $exe.SelectSingleNode('t:WorkingDirectory',$n)){ + throw 'SENSOR_JOURNAL_TASK_XML_EXEC_SHAPE_UNEXPECTED' + } + $expectedExe=Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorSupervisor.exe' + $expectedBroker=Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorBroker.exe' + $expectedOutput=Join-Path $TrustedAppRoot 'cpu_temp_broker.json' + $expectedArgs='--broker "'+$expectedBroker+'" --output "'+$expectedOutput+'"' + if(-not [string]::Equals([string]$command.InnerText,$expectedExe,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$args.InnerText,$expectedArgs,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_TASK_XML_EXEC_NOT_PINNED' + } + $uri=$xml.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$n) + if($uri -and [string]$uri.InnerText -cne '\TaskbarMonitorEnhanced Sensor Broker'){ + throw 'SENSOR_JOURNAL_TASK_XML_URI_UNPINNED' + } + if($principal.SelectSingleNode('t:GroupId',$n)){ + throw 'SENSOR_JOURNAL_TASK_XML_GROUP_PRINCIPAL_FORBIDDEN' + } + if($xml.SelectSingleNode('/t:Task/t:RegistrationInfo/t:SecurityDescriptor',$n)){ + throw 'SENSOR_JOURNAL_TASK_XML_EMBEDDED_SECURITY_DESCRIPTOR_REFUSED' + } + return $true +} + +# R314: isolated experimental cold replay of a fully validated PREPARED +# journal and its prior ScheduledTask XML. The production entrypoint remains +# fail-closed until a separate actual elevated-helper death test is accepted. +function Test-SensorOwnedTaskRegistration( + [string]$TaskName,[string]$ExpectedSid,[string]$TrustedBrokerRoot,[string]$TrustedAppRoot +){ + if([string]::IsNullOrWhiteSpace($TaskName) -or $TaskName.Length -gt 100 -or + $TaskName -notmatch '^[A-Za-z0-9 _-]+$'){ + throw 'SENSOR_COLD_TASK_INVALID_NAME' + } + Import-Module ScheduledTasks -ErrorAction Stop + $existing=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue| + Where-Object {$null -ne $_}) + if($existing.Count -gt 1){throw 'SENSOR_COLD_TASK_DUPLICATE_NAME'} + if($existing.Count -eq 0){return $false} + $task=$existing[0] + $sid=Resolve-SensorPriorTaskPrincipalSid ([string]$task.Principal.UserId) + if($sid -cne $ExpectedSid -or + @($task.Actions).Count -ne 1 -or + @($task.Triggers).Count -ne 1){ + throw 'SENSOR_COLD_TASK_PRINCIPAL_OR_SHAPE_MISMATCH' + } + $expectedExe=Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorSupervisor.exe' + $expectedArgs='--broker "'+(Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorBroker.exe')+ + '" --output "'+(Join-Path $TrustedAppRoot 'cpu_temp_broker.json')+'"' + if(-not [string]::Equals([string]$task.Actions[0].Execute,$expectedExe,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$task.Actions[0].Arguments,$expectedArgs,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_COLD_TASK_UNTRUSTED_EXISTING_ACTION' + } + # Export and validate all privilege-bearing XML fields, including + # highest runlevel, interactive logon and trigger UserId. + $currentXml=[string](Export-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction Stop) + if($TaskName -cne 'TaskbarMonitorEnhanced Sensor Broker'){ + # Test-only URI variation: the real task must always use its fixed + # URI; hosted ephemeral tests are named explicitly and never started. + $xmlDoc=[Xml.XmlDocument]::new() + $xmlDoc.XmlResolver=$null + $settings=[Xml.XmlReaderSettings]::new() + $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit + $settings.XmlResolver=$null + $settings.MaxCharactersInDocument=262144 + $sr=[IO.StringReader]::new($currentXml) + $reader=$null + try{ + $reader=[Xml.XmlReader]::Create($sr,$settings) + $xmlDoc.Load($reader) + }finally{if($reader){$reader.Dispose()};$sr.Dispose()} + $namespace=[Xml.XmlNamespaceManager]::new($xmlDoc.NameTable) + $namespace.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + $uri=$xmlDoc.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$namespace) + if($uri -and [string]$uri.InnerText -cne ('\'+$TaskName)){ + throw 'SENSOR_COLD_TASK_EPHEMERAL_URI_MISMATCH' + } + # Drop only the Scheduler-generated URI before passing it through + # the exact production policy. No other XML fields are changed. + if($uri){[void]$uri.ParentNode.RemoveChild($uri)} + $currentXml=$xmlDoc.OuterXml + } + if(-not (Assert-SensorTrustedOldTaskXml $currentXml $ExpectedSid $TrustedBrokerRoot $TrustedAppRoot)){ + throw 'SENSOR_COLD_TASK_EXPORTED_XML_UNTRUSTED' + } + return $true +} +function Restore-SensorJournalPriorLayerAndTask( + [string]$Journal,[string]$ExpectedSid,[string]$LiveBrokerRoot, + [string]$TrustedAppRoot,[string]$TaskName +){ + # Trusted production code passes the fixed TaskName. The alternate + # prefix is reserved exclusively for bounded disposable test fixtures; + # never register a task under arbitrary string input. + if($TaskName -cne 'TaskbarMonitorEnhanced Sensor Broker' -and + $TaskName -cnotmatch '^TBME-R314-Disposable-[0-9]{5,20}-[AB]$'){ + throw 'SENSOR_COLD_TASK_NAME_NOT_ALLOWLISTED' + } + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$ExpectedSid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_COLD_JOURNAL_EFFECTIVE_SID_MISMATCH' + } + if((Get-SensorJournalTerminal $Journal $ExpectedSid) -cne 'PREPARED'){ + throw 'SENSOR_COLD_JOURNAL_NOT_PREPARED' + } + $record=Assert-SensorDurableJournal $Journal $ExpectedSid + $savedXmlFile=Join-Path $Journal 'task.xml' + Assert-SensorJournalTrustedAcl $savedXmlFile $false + $xmlBytes=[IO.File]::ReadAllBytes($savedXmlFile) + if($xmlBytes.Length -gt 262144 -or + (Get-SensorJournalHash $xmlBytes) -cne [string]$record.TaskHash){ + throw 'SENSOR_COLD_SAVED_TASK_XML_HASH_MISMATCH' + } + $oldXml=[Text.Encoding]::UTF8.GetString($xmlBytes) + if([bool]$record.TaskPresent){ + if(-not (Assert-SensorTrustedOldTaskXml $oldXml $ExpectedSid $LiveBrokerRoot $TrustedAppRoot)){ + throw 'SENSOR_COLD_PRIOR_XML_POLICY_INVALID' + } + }elseif($oldXml.Length -gt 0){ + throw 'SENSOR_COLD_UNEXPECTED_SAVED_TASK_XML' + } + # Reject a foreign or altered live task BEFORE any files are changed. + $previousTaskPresent=Test-SensorOwnedTaskRegistration $TaskName $ExpectedSid $LiveBrokerRoot $TrustedAppRoot + # R347: the original old protected tree is known from the validated + # journal. Pin ProgramFiles/product/Broker directory handles across both + # file replay and Task XML restoration; no DELETE sharing is granted. + # R337 first-install journals are intentionally excluded because the + # expected old BrokerRoot is absent and must be deleted on rollback. + $brokerDirectoryLease=$null + if($TaskName -ceq 'TaskbarMonitorEnhanced Sensor Broker' -and + [bool]$record.OldRootPresent){ + $brokerDirectoryLease=[TBMEProtectedBrokerLease]::new($LiveBrokerRoot) + Assert-SensorJournalTrustedAcl $LiveBrokerRoot $false + } + try { + # R309 prevalidates every saved file/hash, XML and all current live + # member names before the first synthetic file write. Retrying after a + # crash is safe for the same allowlisted bytes and expected task. + $decoded=Restore-SensorJournalOldFilesOnly $Journal $ExpectedSid $LiveBrokerRoot $brokerDirectoryLease + if(-not [string]::Equals($decoded,$oldXml,[StringComparison]::Ordinal)){ + throw 'SENSOR_COLD_REPLAY_XML_DATA_CHANGED' + } + if([bool]$record.TaskPresent){ + if(-not $previousTaskPresent){ + # A unique known task name is required absent, and we never + # use -Force (which could overwrite an unrelated task). + Register-ScheduledTask -TaskPath '\' -TaskName $TaskName -Xml $oldXml -ErrorAction Stop|Out-Null + } + if(-not (Test-SensorOwnedTaskRegistration $TaskName $ExpectedSid $LiveBrokerRoot $TrustedAppRoot)){ + throw 'SENSOR_COLD_REGISTERED_TASK_READBACK_INVALID' + } + }else{ + if($previousTaskPresent){ + Unregister-ScheduledTask -TaskPath '\' -TaskName $TaskName -Confirm:$false -ErrorAction Stop + } + if(Test-SensorOwnedTaskRegistration $TaskName $ExpectedSid $LiveBrokerRoot $TrustedAppRoot){ + throw 'SENSOR_COLD_ABSENT_TASK_NOT_RESTORED' + } + } + Write-SensorJournalTerminal $Journal $ExpectedSid 'ROLLED_BACK' + Finish-SensorTerminalJournal $Journal $ExpectedSid + if([IO.Directory]::Exists($Journal) -or [IO.Directory]::Exists($Journal+'-retired')){ + throw 'SENSOR_COLD_ROLLBACK_JOURNAL_RESIDUE' + } + }finally{ + if($null -ne $brokerDirectoryLease){$brokerDirectoryLease.Dispose()} + } +} + +# R315: verified cold replay is now callable from the trusted PE entrypoint +# before new protected sensor install/uninstall mutation. The operation stays +# fail-closed on another account, foreign global task, invalid journal or +# path/reparse ambiguity; no automatic destructive retry from user Temp. +function Resume-SensorPendingJournal( + [string]$Journal,[string]$Sid,[string]$BrokerRoot, + [string]$AppRoot,[string]$OwnedTaskName +){ + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_COLD_ENTRYPOINT_EFFECTIVE_TOKEN_SID_MISMATCH' + } + Assert-SensorBoundaryNoReparse $Journal + Assert-SensorBoundaryNoReparse ($Journal+'-retired') + if(-not [IO.Directory]::Exists($Journal)){ + # Only a fully terminal, trusted retired stage may be removed. + Resume-SensorCompletedJournal $Journal $Sid + return 'NONE_OR_TERMINAL_COMPLETED' + } + $phase=Get-SensorJournalTerminal $Journal $Sid + if($phase -ne 'PREPARED'){ + Resume-SensorCompletedJournal $Journal $Sid + return 'TERMINAL_COMPLETED' + } + $parent=[IO.Path]::GetDirectoryName($Journal) + Assert-SensorJournalTrustedAcl $parent $false + Assert-SensorBoundaryNoReparse $BrokerRoot + if([IO.Directory]::Exists($BrokerRoot)){ + Assert-SensorJournalTrustedAcl $BrokerRoot $false + } + # The R314 replay function validates every saved file and the prior XML, + # refuses alternate task actions/principal, restores old bytes, confirms + # real Scheduler state, publishes ROLLED_BACK and retires known files. + Restore-SensorJournalPriorLayerAndTask $Journal $Sid $BrokerRoot $AppRoot $OwnedTaskName + if([IO.Directory]::Exists($Journal) -or [IO.Directory]::Exists($Journal+'-retired')){ + throw 'SENSOR_COLD_ENTRYPOINT_RESIDUAL_JOURNAL' + } + return 'PREVIOUS_LAYER_RESTORED' +} + function Capture-PreviousSensorLayer { try{ - Remove-Item -LiteralPath $RollbackRoot -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path $RollbackRoot|Out-Null - + Clear-SensorRollbackBuffer if(Test-Path -LiteralPath $BrokerRoot){ + $script:RollbackFiles=Get-SensorRollbackFiles $BrokerRoot $script:PreviousSensorLayerPresent=$true - Copy-Item -LiteralPath $BrokerRoot -Destination $RollbackBrokerRoot -Recurse -Force } - Import-Module ScheduledTasks - $oldTask=Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue - if($oldTask){ + $task=Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if($task){ + $script:RollbackTaskXmlText=[string](Export-ScheduledTask -TaskName $TaskName) + if([string]::IsNullOrWhiteSpace([string]$script:RollbackTaskXmlText) -or + ([string]$script:RollbackTaskXmlText).Length -gt 262144){throw 'ROLLBACK_XML_MISSING_OR_TOO_LARGE'} $script:PreviousSensorLayerPresent=$true - Export-ScheduledTask -TaskName $TaskName | Set-Content -LiteralPath $RollbackTaskXml -Encoding Unicode } + $script:RollbackCaptured=$true Write-Log ("ROLLBACK_CAPTURE_PASS previous="+$PreviousSensorLayerPresent) return $true }catch{ + Clear-SensorRollbackBuffer Write-Log ('ROLLBACK_CAPTURE_FAIL '+$_.Exception.ToString()) return $false } @@ -107,6 +1545,7 @@ function Restore-PreviousSensorLayer([string]$Reason){ $script:RollbackSucceeded=$false try{ Write-Log ("ROLLBACK_BEGIN reason="+$Reason+" previous="+$PreviousSensorLayerPresent) + if(-not $RollbackCaptured){throw 'ROLLBACK_NOT_CAPTURED'} Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue @@ -114,14 +1553,11 @@ function Restore-PreviousSensorLayer([string]$Reason){ Remove-Item -LiteralPath $BrokerRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $LiveJson,$GpuJson,$StorageJson,$SupervisorState -Force -ErrorAction SilentlyContinue - if(Test-Path -LiteralPath $RollbackBrokerRoot){ - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $BrokerRoot)|Out-Null - Copy-Item -LiteralPath $RollbackBrokerRoot -Destination $BrokerRoot -Recurse -Force + if(@($RollbackFiles).Count -gt 0){ + Restore-SensorRollbackFiles $BrokerRoot $RollbackFiles } - - if(Test-Path -LiteralPath $RollbackTaskXml){ - $xml=Get-Content -LiteralPath $RollbackTaskXml -Raw - Register-ScheduledTask -TaskName $TaskName -Xml $xml -Force|Out-Null + if(-not [string]::IsNullOrEmpty($RollbackTaskXmlText)){ + Register-ScheduledTask -TaskName $TaskName -Xml $RollbackTaskXmlText -Force|Out-Null Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } @@ -138,12 +1574,155 @@ function Restore-PreviousSensorLayer([string]$Reason){ Write-Log "BEGIN mode=$Mode user=$UserId" +# R264-SENSOR-GUARD-BOUNDARY-BEGIN +# Before any shared task/process/file mutation, fail closed if a different +# Windows account owns the fixed legacy machinewide sensor task or processes. +# This is defensive per-account isolation, NOT a machinewide singleton policy. +function Resolve-SensorPrincipalSid([string]$AccountName){ + if([string]::IsNullOrWhiteSpace($AccountName)){throw 'EMPTY_SENSOR_PRINCIPAL'} + if($AccountName -match '^S-1-5-(?:\d+-)+\d+$'){return $AccountName} + $account=[Security.Principal.NTAccount]::new($AccountName) + return [string]$account.Translate([Security.Principal.SecurityIdentifier]).Value +} +function Preserve-OtherUserSensorLayer([string]$Why){ + $script:ActiveArchitecture='SHARED_PROTECTED_SENSOR_LAYER_LEFT_UNTOUCHED' + Write-Log ('CROSS_USER_SENSOR_SAFETY_REFUSAL reason='+$Why+' mode='+$Mode) + Write-Result 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' ('Protected sensors were not changed: '+$Why+'. The per-user main app remains available with unavailable protected metrics as N/A.') 'NOT_STARTED' $null $false $false $false + exit 0 +} +try{ + $expectedUserSid=Resolve-SensorPrincipalSid $UserId + Import-Module ScheduledTasks -ErrorAction Stop + $lookupErrors=@() + $existing=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue -ErrorVariable +lookupErrors | + Where-Object {$null -ne $_}) + if(@($lookupErrors|Where-Object {$_.CategoryInfo.Category -ne [Management.Automation.ErrorCategory]::ObjectNotFound}).Count){ + throw 'TASK_SCHEDULER_LOOKUP_NOT_TRUSTED' + } + if($existing.Count -gt 1){throw 'MULTIPLE_GLOBAL_TASK_MATCHES'} + if($existing.Count -eq 1){ + $oldSid=Resolve-SensorPrincipalSid ([string]$existing[0].Principal.UserId) + if($oldSid -cne $expectedUserSid){ + Preserve-OtherUserSensorLayer 'ANOTHER_ACCOUNT_OWNS_GLOBAL_SCHEDULED_TASK' + } + }elseif(Test-Path -LiteralPath $BrokerRoot){ + # R344: Uninstall can be killed after deleting the owner marker but + # before removing BrokerRoot (R343 native A/B reproduced this). Never + # trust a markerless tree merely because the caller requests Uninstall. + # Only an admin-owned, fully hashed PREPARED journal with the old owner + # marker itself among its verified snapshots proves this same-account + # partial deletion. Other modes/foreign roots continue to fail closed. + Assert-SensorBoundaryNoReparse $SensorOwnerMarker + if(-not (Test-Path -LiteralPath $SensorOwnerMarker -PathType Leaf)){ + $trustedPartialUninstall=$false + if($Mode -ceq 'Uninstall' -and + [IO.Directory]::Exists($SensorJournalRoot)){ + $pending=Assert-SensorDurableJournal $SensorJournalRoot $expectedUserSid + $newList=@($pending.ExpectedNewFiles|Where-Object{$null -ne $_}) + $ownerRecords=@($pending.Files|Where-Object{ + [string]$_.Rel -ceq '.tbme-owner.sid' + }) + if([bool]$pending.OldRootPresent -and $newList.Count -eq 0 -and + $ownerRecords.Count -eq 1 -and + (Get-SensorJournalTerminal $SensorJournalRoot $expectedUserSid) -ceq 'PREPARED'){ + $savedMarker=Join-Path (Join-Path $SensorJournalRoot 'files') '.tbme-owner.sid' + Assert-SensorJournalTrustedAcl $savedMarker $false + if([IO.File]::ReadAllText($savedMarker,[Text.Encoding]::ASCII).Trim() -ceq $expectedUserSid){ + $trustedPartialUninstall=$true + } + } + } + if(-not $trustedPartialUninstall){ + Preserve-OtherUserSensorLayer 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' + } + Write-Log 'R344_SAME_OWNER_PREPARED_UNINSTALL_SNAPSHOT_ACCEPTED' + }elseif([IO.File]::ReadAllText($SensorOwnerMarker,[Text.Encoding]::ASCII).Trim() -cne $expectedUserSid){ + Preserve-OtherUserSensorLayer 'PROTECTED_SENSOR_OWNER_MARKER_MISMATCH' + } + Write-Log 'PARTIAL_PROTECTED_SENSOR_BROKER_SAME_OWNER_ACCEPTED' + } + $sensorProcesses=@(Get-CimInstance Win32_Process -Filter "Name='TaskbarMonitorSensorBroker.exe' OR Name='TaskbarMonitorSensorSupervisor.exe'" -ErrorAction Stop | + Where-Object {$null -ne $_}) + foreach($proc in $sensorProcesses){ + $owner=Invoke-CimMethod -InputObject $proc -MethodName GetOwnerSid -ErrorAction Stop + if([int]$owner.ReturnValue -ne 0 -or + [String]::IsNullOrWhiteSpace([string]$owner.Sid) -or + [string]$owner.Sid -cne $expectedUserSid){ + Preserve-OtherUserSensorLayer 'SENSOR_PROCESS_OWNER_IS_UNKNOWN_OR_OTHER_ACCOUNT' + } + } +}catch{ + Preserve-OtherUserSensorLayer ('UNVERIFIED_MACHINEWIDE_SENSOR_OWNERSHIP_'+$_.Exception.GetType().Name) +} +# R264-SENSOR-GUARD-BOUNDARY-END + +# R315: BEFORE new install/uninstall mutations, restore an interrupted +# PREPARED previous protected layer only under exact same-account proof. +# The old saved task/files are independently authenticated before replay. +try{ + $resumeResult=Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName + if($resumeResult -ceq 'PREVIOUS_LAYER_RESTORED'){ + $script:ActiveArchitecture='PREVIOUS_PROTECTED_SENSOR_LAYER_RESTORED' + Write-Log 'R315_PRIOR_PROTECTED_SENSOR_COLD_RECOVERY_COMPLETE' + Write-Result 'DEGRADED_PREVIOUS_LAYER_RESTORED' 'The interrupted protected sensor update was rolled back to the previous verified owner task and exact prior binaries. The per-user application remains available; run Repair sensors again for a new update.' 'NOT_STARTED' $null $false $false $false + exit 0 + } +}catch{ + $script:ActiveArchitecture='PROTECTED_JOURNAL_RECOVERY_REQUIRED' + Write-Log ('R315_PENDING_PROTECTED_JOURNAL_COLD_RESTORE_NOT_COMPLETE '+$_.Exception.Message) + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensor recovery could not be fully verified; the prior journal was retained, and no subsequent new protected installation was started. A verified same-owner recovery is required.' 'NOT_STARTED' $null $false $false $false + exit 0 +} + if($Mode -eq 'Uninstall'){ try{ + # R344: authenticated write-ahead snapshot before the FIRST task or + # protected Broker deletion. A torn uninstall must restore the exact + # previous owner task/files, not silently leave a markerless folder. + $oldRootPresent=[IO.Directory]::Exists($BrokerRoot) + $oldTaskPresent=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue | + Where-Object{$null -ne $_}).Count -ne 0 + if($oldRootPresent -or $oldTaskPresent){ + if(-not(Capture-PreviousSensorLayer)){ + throw 'R344_UNINSTALL_PRIOR_OWNER_SNAPSHOT_NOT_CAPTURED' + } + $snapshotParent=[IO.Path]::GetDirectoryName($SensorJournalRoot) + Assert-SensorBoundaryNoReparse $snapshotParent + if(-not [IO.Directory]::Exists($snapshotParent)){ + [void][IO.Directory]::CreateDirectory($snapshotParent) + } + Assert-SensorJournalTrustedAcl $snapshotParent $false + # Empty expected-new set distinguishes an uninstall snapshot + # from the existing install PREPARED journals. Hashes and SID are + # checked during markerless cold recovery before any mutation. + New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid $RollbackFiles $RollbackTaskXmlText ([string[]]@()) $oldRootPresent + Write-Log 'R344_DURABLE_PREPARED_BEFORE_UNINSTALL_TASK_FILE_DELETION' + } Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $BrokerRoot -Recurse -Force -ErrorAction SilentlyContinue + $liveTasks=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue | + Where-Object{$null -ne $_}) + if($liveTasks.Count -gt 1){throw 'R344_UNINSTALL_MULTIPLE_GLOBAL_TASKS'} + if($liveTasks.Count -eq 1){ + Unregister-ScheduledTask -TaskPath '\' -TaskName $TaskName -Confirm:$false -ErrorAction Stop + } + Assert-SensorBoundaryNoReparse $BrokerRoot + if([IO.Directory]::Exists($BrokerRoot)){ + Remove-Item -LiteralPath $BrokerRoot -Recurse -Force -ErrorAction Stop + } + if([IO.Directory]::Exists($BrokerRoot) -or + @((Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue | + Where-Object{$null -ne $_})).Count){ + throw 'R344_PROTECTED_UNINSTALL_POSTCONDITION_FAILED' + } + if([IO.Directory]::Exists($SensorJournalRoot)){ + Write-SensorJournalTerminal $SensorJournalRoot $validatedSensorSid 'COMMITTED' + Finish-SensorTerminalJournal $SensorJournalRoot $validatedSensorSid + } + if([IO.Directory]::Exists($SensorJournalRoot) -or + [IO.Directory]::Exists($SensorJournalRoot+'-retired')){ + throw 'R344_UNINSTALL_TERMINAL_JOURNAL_RESIDUE' + } Write-Log 'UNINSTALL protected sensor layer removed' Write-Result 'UNINSTALLED' 'Protected sensor layer removed.' 'NOT_APPLICABLE' $null $false $false $false exit 0 @@ -154,9 +1733,82 @@ if($Mode -eq 'Uninstall'){ } } +# R294: one exclusive read; installed bytes are the same verified buffer. +function Get-VerifiedSensorPayloadBytes([string]$File,[string]$Expected,[long]$MaxBytes){ + if($Expected -cnotmatch '^[A-F0-9]{64}$'){throw 'SENSOR_PAYLOAD_SHA_NOT_PINNED'} + Assert-SensorBoundaryNoReparse $File + $stream=[IO.FileStream]::new($File,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + if($stream.Length -lt 1 -or $stream.Length -gt $MaxBytes){throw 'SENSOR_PAYLOAD_SIZE_INVALID'} + $buffer=[byte[]]::new([int]$stream.Length) + $offset=0 + while($offset -lt $buffer.Length){ + $count=$stream.Read($buffer,$offset,$buffer.Length-$offset) + if($count -le 0){throw 'SENSOR_PAYLOAD_SHORT_READ'} + $offset+=$count + } + $sha=[Security.Cryptography.SHA256]::Create() + try{$actual=[BitConverter]::ToString($sha.ComputeHash($buffer)).Replace('-','')} + finally{$sha.Dispose()} + if($actual -cne $Expected){throw 'SENSOR_PAYLOAD_EMBEDDED_SHA_MISMATCH'} + return ,$buffer + }finally{$stream.Dispose()} +} + +# R295: only the PE-pinned, single-read ZIP buffer supplies protected dependency bytes. +function Get-VerifiedSensorBackendEntries([string]$ZipFile,[string]$ExpectedZipSHA256){ + $bytes=Get-VerifiedSensorPayloadBytes $ZipFile $ExpectedZipSHA256 16777216 + Add-Type -AssemblyName System.IO.Compression + $mem=[IO.MemoryStream]::new([byte[]]$bytes) + $archive=[IO.Compression.ZipArchive]::new($mem,[IO.Compression.ZipArchiveMode]::Read,$false) + $names=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $result=[Collections.Generic.List[object]]::new() + [long]$sum=0 + try{ + if($archive.Entries.Count -ne 43){throw 'SENSOR_BACKEND_ZIP_ENTRY_COUNT'} + foreach($entry in $archive.Entries){ + $name=[string]$entry.FullName + if([string]::IsNullOrWhiteSpace($name) -or $name.Contains('\') -or + $name.Contains(':') -or $name.StartsWith('/') -or + [IO.Path]::IsPathRooted($name)){throw 'SENSOR_BACKEND_ZIP_UNSAFE_ENTRY'} + $parts=@($name.Split('/')) + foreach($part in $parts){ + if($part -ceq '.' -or $part -ceq '..' -or + $part.EndsWith('.') -or $part.EndsWith(' ') -or + $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'SENSOR_BACKEND_ZIP_UNSAFE_ENTRY' + } + } + if(-not $names.Add($name)){throw 'SENSOR_BACKEND_ZIP_DUPLICATE_ENTRY'} + $fileType=($entry.ExternalAttributes -shr 16) -band 0xF000 + if($fileType -eq 0xA000){throw 'SENSOR_BACKEND_ZIP_LINK_REFUSED'} + if($entry.Length -le 0 -or $entry.Length -gt 8388608){ + throw 'SENSOR_BACKEND_ZIP_FILE_SIZE' + } + $sum+=$entry.Length + if($sum -gt 33554432){throw 'SENSOR_BACKEND_ZIP_TOTAL_SIZE'} + $buffer=[byte[]]::new([int]$entry.Length) + $input=$entry.Open() + try{ + $offset=0 + while($offset -lt $buffer.Length){ + $read=$input.Read($buffer,$offset,$buffer.Length-$offset) + if($read -le 0){throw 'SENSOR_BACKEND_ZIP_TRUNCATED_ENTRY'} + $offset+=$read + } + if($input.ReadByte() -ne -1){throw 'SENSOR_BACKEND_ZIP_UNDECLARED_EXTRA_DATA'} + }finally{$input.Dispose()} + $result.Add([pscustomobject]@{Rel=$name;Bytes=$buffer}) + } + if(-not $names.Contains('LibreHardwareMonitorLib.dll')){ + throw 'SENSOR_BACKEND_ZIP_MISSING_LIBRARY' + } + return ,$result.ToArray() + }finally{$archive.Dispose();$mem.Dispose()} +} + $BrokerPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorBroker.exe' $SupervisorPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorSupervisor.exe' -$PawnPayload=Join-Path $PayloadDir 'PawnIO_setup.exe' $BackendRoot=Join-Path $AppRoot 'SensorBackend\LibreHardwareMonitor-0.9.6' $LiveJson=Join-Path $AppRoot 'cpu_temp_broker.json' $GpuJson=Join-Path $AppRoot 'gpu_temp_broker.json' @@ -178,69 +1830,93 @@ if(-not$requiredOk){ exit 0 } -# Public OSS Setup never bundles the separately licensed official PawnIO installer. -# Reuse an already installed compatible PawnIO; otherwise degrade before any service -# mutation, UAC driver installation, network request or host rollback snapshot. +# R297: NEVER execute a user-writable PawnIO installer from Temp under UAC. +# A forged PawnIO_setup.exe can be created after payload extraction, even if +# the official open-source PE carries no proprietary PawnIO resource. +# Only an already installed compatible PawnIO is reused; otherwise fail closed +# and request a separate, explicit upstream installer with user consent. $existingPawn=Get-PawnIOVersion $pawnAlreadyCompatible=$false if($existingPawn){ try{if([version]$existingPawn -ge [version]'2.2.0'){$pawnAlreadyCompatible=$true}}catch{} } -if(-not $pawnAlreadyCompatible -and !(Test-Path -LiteralPath $PawnPayload -PathType Leaf)){ +if(-not $pawnAlreadyCompatible){ $ActiveArchitecture='PAWNIO_EXTERNAL_INSTALL_REQUIRED' - Write-Log 'PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED no bundled proprietary binary and no silent driver download' + Write-Log 'PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED never executing mutable Temp PawnIO binary under UAC' Write-Result 'DEGRADED' ('Optional protected hardware sensors require the official PawnIO 2.2.0 driver, installed separately with user consent from https://github.com/namazso/PawnIO.Setup/releases/tag/2.2.0 . The main application works without it and unavailable temperatures show N/A. No driver or protected sensor service was modified.') 'EXTERNAL_INSTALL_REQUIRED' $null $false $false $false exit 0 } +# R294: Verify pinned embedded identity before touching protected services. +try{ + $VerifiedBrokerBytes=Get-VerifiedSensorPayloadBytes $BrokerPayload $ExpectedBrokerSHA256 16777216 + $VerifiedSupervisorBytes=Get-VerifiedSensorPayloadBytes $SupervisorPayload $ExpectedSupervisorSHA256 16777216 + $VerifiedBackendEntries=Get-VerifiedSensorBackendEntries (Join-Path $PayloadDir 'LibreHardwareMonitor.zip') $ExpectedBackendZipSHA256 +}catch{ + Write-Log ('SENSOR_PAYLOAD_PROVENANCE_REFUSED '+$_.Exception.Message) + Write-Result 'DEGRADED' 'Protected sensor payload provenance not verified; prior service preserved.' 'NOT_STARTED' $null $false $false $false + exit 0 +} if(-not(Capture-PreviousSensorLayer)){ $ActiveArchitecture='PREVIOUS_SENSOR_LAYER_UNCHANGED' Write-Result 'DEGRADED' 'Setup could not capture a rollback snapshot of the existing protected sensor layer, so it did not modify that layer.' 'NOT_STARTED' $null $false $false $false exit 0 } +# R308: preserve old protected bytes and task XML BEFORE writing ownership +# markers, stopping tasks or replacing protected files. +try{ + $parent=[IO.Path]::GetDirectoryName($SensorJournalRoot) + Assert-SensorBoundaryNoReparse $parent + if(-not [IO.Directory]::Exists($parent)){ + [void][IO.Directory]::CreateDirectory($parent) + } + Assert-SensorJournalTrustedAcl $parent $false + $expectedNew=[Collections.Generic.List[string]]::new() + foreach($name in @('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','.tbme-owner.sid')){ + $expectedNew.Add($name) + } + foreach($verified in $VerifiedBackendEntries){ + $expectedNew.Add(([string]$verified.Rel).Replace('/','\')) + } + New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid $RollbackFiles $RollbackTaskXmlText $expectedNew.ToArray() ([IO.Directory]::Exists($BrokerRoot)) + Write-Log 'R308_DURABLE_READY_VERIFIED_BEFORE_PROTECTED_MUTATION' +}catch{ + Clear-SensorRollbackBuffer + Write-Log ('R308_PROTECTED_JOURNAL_PREWRITE_REFUSED '+$_.Exception.Message) + Write-Result 'DEGRADED' 'Protected sensor snapshot could not be durably verified before mutation; prior protected services were preserved.' 'NOT_STARTED' $null $false $false $false + exit 0 +} $pawnStatus='ALREADY_PRESENT' $pawnExit=$null $rebootRequired=$false -$installedPawn=Get-PawnIOVersion -$needPawn=$true -if($installedPawn){ - try{ - if([version]$installedPawn -ge [version]'2.2.0'){$needPawn=$false} - }catch{} -} +Write-Log "PAWNIO_ALREADY_PRESENT version=$existingPawn" -if($needPawn){ - $pawnStatus='STARTING' - Write-Log 'PAWNIO_INSTALL_START timeoutSec=60' - try{ - $pawnProcess=Start-Process -FilePath $PawnPayload -ArgumentList '-install -silent' -PassThru - if(-not $pawnProcess.WaitForExit(60000)){ - Write-Log "PAWNIO_TIMEOUT pid=$($pawnProcess.Id)" - try{Stop-Process -Id $pawnProcess.Id -Force -ErrorAction SilentlyContinue}catch{} - $pawnStatus='TIMEOUT' - }else{ - $pawnExit=[int]$pawnProcess.ExitCode - if($pawnExit -eq 0){ - $pawnStatus='INSTALLED' - Write-Log 'PAWNIO_INSTALL_PASS exit=0' - }elseif($pawnExit -eq 3010){ - $pawnStatus='REBOOT_REQUIRED' - $rebootRequired=$true - Write-Log 'PAWNIO_INSTALL_REBOOT_REQUIRED exit=3010' - }else{ - $pawnStatus='FAILED' - Write-Log "PAWNIO_INSTALL_FAILED exit=$pawnExit" - } +# R303: publish protected SID ownership BEFORE unregistering the old task. +# The marker is never accepted from writable Temp/AppData and is exclusive. +function Assert-OrRecordSensorOwner { + Assert-SensorBoundaryNoReparse $BrokerRoot + Assert-SensorBoundaryNoReparse $SensorOwnerMarker + if(Test-Path -LiteralPath $SensorOwnerMarker){ + if([IO.File]::ReadAllText($SensorOwnerMarker,[Text.Encoding]::ASCII).Trim() -cne $validatedSensorSid){ + throw 'PROTECTED_SENSOR_OWNER_MARKER_MISMATCH' } - }catch{ - $pawnStatus='EXCEPTION' - Write-Log ('PAWNIO_INSTALL_EXCEPTION '+$_.Exception.ToString()) + return } -}else{ - Write-Log "PAWNIO_ALREADY_PRESENT version=$installedPawn" + [void][IO.Directory]::CreateDirectory($BrokerRoot) + Assert-SensorBoundaryNoReparse $SensorOwnerMarker + $bytes=[Text.Encoding]::ASCII.GetBytes($validatedSensorSid) + $stream=[IO.FileStream]::new($SensorOwnerMarker,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} + finally{$stream.Dispose()} +} +try{ + Assert-OrRecordSensorOwner +}catch{ + Write-Log ('PROTECTED_SENSOR_OWNER_MARKER_SETUP_REFUSED '+$_.Exception.Message) + Write-Result 'DEGRADED' 'Protected sensor ownership could not be persisted before modification; existing services were preserved.' 'NOT_STARTED' $null $false $false $false + exit 0 } - $taskInstalled=$false try{ Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | @@ -257,15 +1933,16 @@ try{ Remove-Item -LiteralPath $LiveJson,$GpuJson,$StorageJson,$SupervisorState -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $BrokerRoot|Out-Null - Copy-Item -LiteralPath $BrokerPayload -Destination (Join-Path $BrokerRoot 'TaskbarMonitorSensorBroker.exe') -Force - Copy-Item -LiteralPath $SupervisorPayload -Destination (Join-Path $BrokerRoot 'TaskbarMonitorSensorSupervisor.exe') -Force + [IO.File]::WriteAllBytes((Join-Path $BrokerRoot 'TaskbarMonitorSensorBroker.exe'),$VerifiedBrokerBytes) + [IO.File]::WriteAllBytes((Join-Path $BrokerRoot 'TaskbarMonitorSensorSupervisor.exe'),$VerifiedSupervisorBytes) - Get-ChildItem -LiteralPath $BackendRoot -File -Recurse | ForEach-Object { - $relative=$_.FullName.Substring($BackendRoot.Length).TrimStart('\') - $destination=Join-Path $BrokerRoot $relative + foreach($verified in $VerifiedBackendEntries){ + $destination=Join-Path $BrokerRoot ($verified.Rel.Replace('/','\')) + Assert-SensorBoundaryNoReparse $destination $parent=Split-Path -Parent $destination if($parent){New-Item -ItemType Directory -Force -Path $parent|Out-Null} - Copy-Item -LiteralPath $_.FullName -Destination $destination -Force + Assert-SensorBoundaryNoReparse $destination + [IO.File]::WriteAllBytes($destination,[byte[]]$verified.Bytes) } Import-Module ScheduledTasks @@ -356,16 +2033,33 @@ if($taskInstalled -and -not$SupervisorHealthy -and -not$rebootRequired){ } if($RollbackPerformed){ - if($RollbackSucceeded){ - Write-Log 'R21_INSTALL_DEGRADED_ROLLED_BACK' - Write-Result 'DEGRADED_ROLLED_BACK' 'R21 protected sensor validation did not pass, so the previous protected sensor layer was restored automatically. The main application remains usable; use Repair protected sensors to retry R21 later.' $pawnStatus $pawnExit $false $false $false - }else{ - Write-Log 'R21_INSTALL_DEGRADED_ROLLBACK_FAILED' - Write-Result 'DEGRADED_ROLLBACK_FAILED' 'R21 protected sensor validation did not pass and automatic rollback also failed. The main application remains usable, but protected telemetry requires repair.' $pawnStatus $pawnExit $false $false $false - } + # The in-memory rollback may have returned success, but its old task XML + # has NOT yet been independently verified by a cold privileged process. + # Keep PREPARED and its prior bytes; do not assert ROLLED_BACK or delete. + Write-Log ('R311_INPROCESS_ROLLBACK_JOURNAL_PENDING verifiedInMemory='+$RollbackSucceeded) + Clear-SensorRollbackBuffer + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensor installation attempted rollback, but durable previous task and file recovery is not yet independently verified. The journal was retained for verified recovery; per-user monitoring remains usable.' $pawnStatus $pawnExit $false $false $false exit 0 } - +if(-not $taskInstalled -or -not $SupervisorHealthy){ + # No claim that a partially installed protected layer is committed. + Write-Log 'R311_PROTECTED_HEALTH_NOT_COMMITTED_KEEP_READY' + Clear-SensorRollbackBuffer + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensor activation was not verified healthy; the rollback snapshot is retained rather than incorrectly committed or discarded.' $pawnStatus $pawnExit $false $false $false + exit 0 +} +try{ + Complete-HealthySensorJournal $SensorJournalRoot $validatedSensorSid $taskInstalled $SupervisorHealthy + Write-Log 'R311_PROTECTED_JOURNAL_COMMITTED_AND_RETIRED' +}catch{ + Write-Log ('R311_TERMINAL_COMMIT_CLEANUP_DEGRADED '+$_.Exception.Message) + Clear-SensorRollbackBuffer + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensors reached their health check, but the journal terminal completion could not be verified. The retained journal must be reconciled before another protected update.' $pawnStatus $pawnExit $false $false $false + exit 0 +} +# Verified protected healthy path completed and the durable terminal +# snapshot was retired; now release its redundant in-memory backup. +Clear-SensorRollbackBuffer if($SupervisorHealthy -and $healthy){ Write-Log "R21_SENSOR_READY cpu=$currentC" Write-Result 'READY' 'R21 protected sensor supervisor is healthy across CPU, GPU and storage lanes; CPU temperature data is active.' $pawnStatus $pawnExit $false $taskInstalled $true diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 3d8c44b8..2ab64162 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -20,20 +20,23 @@ [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.2.0")] -[assembly: AssemblyFileVersion("1.7.2.0")] -[assembly: AssemblyInformationalVersion("1.7.2+r155")] +[assembly: AssemblyVersion("1.7.8.0")] +[assembly: AssemblyFileVersion("1.7.8.0")] +[assembly: AssemblyInformationalVersion("1.7.8+r286")] internal static partial class SetupProgram { const string Product="Taskbar Monitor Enhanced"; - const string Version="1.7.2"; + const string Version="1.7.8"; const string SensorProtocolVersion="1.1.2+r21"; const string SensorSupervisorVersion="1.3.0+r33"; const string Publisher="Dr. Ali-Akbar Emadeddin"; const string AppFolder="TaskbarMonitorEnhanced"; const string UninstallKey=@"Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced"; const int CompatibleSensorStateFreshSeconds=15; + // SHA-256 from build/dependencies.lock.json. Unlike a digest derived from + // the same unsigned PE at runtime, this rejects corrupted embedded ZIP bytes. + const string PinnedBackendZipSHA256="086D9F1B5A99E643EDC2CFAAAC16051685B551E4C5AC0B32A57C58C0E529C001"; static readonly string[] RequiredResources=new string[] { "Payload.TaskbarMonitorEnhanced.exe", @@ -58,12 +61,97 @@ internal static partial class SetupProgram "Payload.COPYRIGHT_AND_ATTRIBUTION.md", "Payload.AI_ASSISTED_DEVELOPMENT.md", "Payload.THIRD_PARTY_NOTICES.md", - "Payload.RELEASE_NOTES_v1.7.2.md", + "Payload.RELEASE_NOTES_v1.7.8.md", "Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md", "Payload.TaskbarMonitorEnhanced_Setup.cs" }; - static string AppRoot { get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),AppFolder); } } + // R316: the account-scoped installation must never follow a redirected + // or linked LocalAppData directory into another user's/system tree. + // Validate OS known-folder identity and ancestors before any I/O sink. + static string CanonicalAppRootFromKnownFolders(string knownLocal,string knownProfile) + { + if(String.IsNullOrWhiteSpace(knownLocal) || String.IsNullOrWhiteSpace(knownProfile) || + !Path.IsPathRooted(knownLocal) || !Path.IsPathRooted(knownProfile)) + throw new InvalidDataException("Canonical user profile or LocalAppData unavailable."); + string profile=Path.GetFullPath(knownProfile); + string local=Path.GetFullPath(knownLocal); + if(profile.StartsWith(@"\\",StringComparison.Ordinal) || local.StartsWith(@"\\",StringComparison.Ordinal) || + String.Equals(profile,Path.GetPathRoot(profile),StringComparison.OrdinalIgnoreCase) || + String.Equals(local,Path.GetPathRoot(local),StringComparison.OrdinalIgnoreCase) || + !Regex.IsMatch(profile,@"^[A-Za-z]:\\") || + !Regex.IsMatch(local,@"^[A-Za-z]:\\")) + throw new InvalidDataException("Canonical per-user installation rejects UNC or root-only paths."); + profile=profile.TrimEnd(Path.DirectorySeparatorChar); + local=local.TrimEnd(Path.DirectorySeparatorChar); + string expected=Path.GetFullPath(Path.Combine(profile,"AppData","Local")).TrimEnd(Path.DirectorySeparatorChar); + if(!String.Equals(local,expected,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("LocalAppData is not the canonical local folder of this user's profile."); + string app=Path.GetFullPath(Path.Combine(local,AppFolder)); + string prefix=local+Path.DirectorySeparatorChar; + if(app.Length>1024 || !app.StartsWith(prefix,StringComparison.OrdinalIgnoreCase) || + !String.Equals(Path.GetFileName(app),AppFolder,StringComparison.Ordinal)) + throw new InvalidDataException("Application path escaped the per-user LocalAppData root."); + CheckRootAncestorsNoReparse(app); + return app; + } + static void CheckRootAncestorsNoReparse(string absolutePath) + { + string cursor=Path.GetFullPath(absolutePath); + while(!String.IsNullOrEmpty(cursor)) + { + try{ + if((File.GetAttributes(cursor)&FileAttributes.ReparsePoint)!=0) + throw new IOException("Canonical user installation path contains a reparse point."); + }catch(FileNotFoundException){}catch(DirectoryNotFoundException){} + string parent=Path.GetDirectoryName(cursor); + if(String.IsNullOrEmpty(parent) || String.Equals(parent,cursor,StringComparison.OrdinalIgnoreCase)) + break; + cursor=parent; + } + } + static string AppRoot { + get { return CanonicalAppRootFromKnownFolders( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); } + } + // R327: elevated Sensor Helper accepts only direct children of the + // known profile's AppData\Local\Temp. Do not use mutable TEMP/TMP env. + // Reparse checks are fail-closed at these instants, not an atomic + // defence against concurrent same-user directory replacement. + static string CanonicalSensorTempFromKnownFolders(string knownLocal,string knownProfile) + { + string app=CanonicalAppRootFromKnownFolders(knownLocal,knownProfile); + string local=Path.GetDirectoryName(app); + if(String.IsNullOrWhiteSpace(local)) + throw new InvalidDataException("Canonical user Temp parent is unavailable."); + string temp=Path.GetFullPath(Path.Combine(local,"Temp")); + if(temp.Length>1024 || + !String.Equals(Path.GetDirectoryName(temp),local,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Canonical sensor Temp directory escaped its profile."); + CheckRootAncestorsNoReparse(temp); + return temp; + } + + static string CreatePrivateSensorPayloadDirectory(string prefix) + { + if(prefix!="tbme_setup_" && prefix!="tbme_uninstall_") + throw new InvalidDataException("Unexpected private sensor payload prefix."); + string root=CanonicalSensorTempFromKnownFolders( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + Directory.CreateDirectory(root); + CheckRootAncestorsNoReparse(root); + string payload=Path.GetFullPath(Path.Combine(root,prefix+Guid.NewGuid().ToString("N"))); + if(!String.Equals(Path.GetDirectoryName(payload),root,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Private sensor payload escaped canonical Temp."); + CheckRootAncestorsNoReparse(payload); + if(File.Exists(payload) || Directory.Exists(payload)) + throw new IOException("Private sensor payload name already exists."); + Directory.CreateDirectory(payload); + CheckRootAncestorsNoReparse(payload); + return payload; + } static string AppExe { get { return Path.Combine(AppRoot,"TaskbarMonitorEnhanced.exe"); } } static string SensorResultPath { get { return Path.Combine(AppRoot,"Logs","sensor_install_result.json"); } } static string MainLaunchMode="PENDING"; @@ -94,6 +182,68 @@ static void Extract(string resource,string path) input.CopyTo(output); } + // R322: consume only the PE-embedded ZIP stream, never a replaceable Temp ZIP. + // Validate the complete archive before modifying the existing user backend. + static void ExtractBackendFromArchive(Stream zipStream,string backendRoot) + { + if(zipStream==null || !zipStream.CanRead || !zipStream.CanSeek || + zipStream.Length>10L*1024*1024) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_UNREADABLE"); + using(ZipArchive archive=new ZipArchive(zipStream,ZipArchiveMode.Read,true)) + { + HashSet seen=new HashSet(StringComparer.OrdinalIgnoreCase); + string root=Path.GetFullPath(backendRoot).TrimEnd(Path.DirectorySeparatorChar); + string prefix=root+Path.DirectorySeparatorChar; + long total=0; + foreach(ZipArchiveEntry entry in archive.Entries) + { + string name=entry.FullName; + if(String.IsNullOrEmpty(name) || name.StartsWith("/",StringComparison.Ordinal) || + name.EndsWith("/",StringComparison.Ordinal) || name.IndexOf('\\')>=0 || + name.IndexOf(':')>=0) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PATH_REFUSED"); + foreach(string segment in name.Split('/')) + if(String.IsNullOrEmpty(segment) || segment=="." || segment==".." || + segment.EndsWith(".",StringComparison.Ordinal) || + segment.EndsWith(" ",StringComparison.Ordinal) || + segment.IndexOfAny(Path.GetInvalidFileNameChars())>=0) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PATH_REFUSED"); + string target=Path.GetFullPath(Path.Combine(root, + name.Replace('/',Path.DirectorySeparatorChar))); + if(!target.StartsWith(prefix,StringComparison.OrdinalIgnoreCase) || + !seen.Add(name)) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PATH_OR_DUPLICATE"); + int unixType=(entry.ExternalAttributes>>16)&0xF000; + int winFlags=(entry.ExternalAttributes>>16)&(int)FileAttributes.ReparsePoint; + if(unixType==0xA000 || winFlags!=0) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_LINK_REFUSED"); + if(entry.Length<0 || entry.Length>8L*1024*1024 || + total+entry.Length>12203773L) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_SIZE_REFUSED"); + total+=entry.Length; + } + if(archive.Entries.Count!=43 || seen.Count!=43 || total!=12203773L) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PIN_MISMATCH"); + + // R324: .NET Framework ZIP extraction does not authenticate the + // stored CRC or decompressed bytes. Reject any archive whose exact + // compressed bytes differ from the pinned dependency BEFORE + // deleting the previous per-user backend. + zipStream.Position=0; + using(SHA256 sha=SHA256.Create()) + { + string actual=BitConverter.ToString(sha.ComputeHash(zipStream)).Replace("-",""); + if(!String.Equals(actual,PinnedBackendZipSHA256,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_SHA_REFUSED"); + } + zipStream.Position=0; + CheckRootAncestorsNoReparse(root); + if(Directory.Exists(root))Directory.Delete(root,true); + Directory.CreateDirectory(root); + archive.ExtractToDirectory(root); + } + } + static void StopProcess(string name) { try{ @@ -104,13 +254,157 @@ static void StopProcess(string name) }catch{} } - static int ElevatedHelper(string helper,string args,int timeoutMs) + // R293: PowerShell code is transported from this immutable, embedded PE resource + // via an in-memory command. Never elevate a .ps1 from writable Temp. + static string Base64Utf8Argument(string value) + { + if(String.IsNullOrWhiteSpace(value) || value.Length>4096) + throw new InvalidDataException("Sensor helper argument missing or too long."); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + } + + // R335: only compact trusted code OUTSIDE its exact native Add-Type + // here-string. Preserve every byte/line inside the C# literal so the + // unchanged OS-PowerShell helper retains syntactically valid source. + // No caller-controlled text is interpolated into this trusted resource. + static string CompactTrustedHelperSource(string source) + { + if(String.IsNullOrWhiteSpace(source)) + throw new InvalidDataException("Trusted sensor helper source unavailable."); + const string nativeOpen="Add-Type -TypeDefinition @'"; + int opened=0,closed=0; + bool inNative=false; + StringBuilder compact=new StringBuilder(); + foreach(string raw in source.Replace("\r\n","\n").Split('\n')) + { + string line=raw.Trim(); + if(inNative) + { + // PS 5.1 requires the closing delimiter at column zero. + compact.Append(raw).Append('\n'); + if(String.Equals(raw,"'@",StringComparison.Ordinal)) + { + inNative=false; + closed++; + } + continue; + } + if(line.Length==0 || line.StartsWith("#",StringComparison.Ordinal))continue; + if(line.EndsWith("@'",StringComparison.Ordinal) || + line.EndsWith("@\"",StringComparison.Ordinal)) + { + if(!String.Equals(line,nativeOpen,StringComparison.Ordinal) || opened!=0) + throw new InvalidDataException("Unexpected trusted sensor helper here-string."); + opened++; + inNative=true; + } + if(String.Equals(line,"'@",StringComparison.Ordinal) || + String.Equals(line,"\"@",StringComparison.Ordinal)) + throw new InvalidDataException("Unexpected trusted sensor helper here-string terminator."); + if(line[line.Length-1]==(char)0x60) + throw new InvalidDataException("Trusted sensor helper cannot compact continuation lines."); + compact.Append(line).Append('\n'); + } + if(inNative || opened!=closed || opened>1) + throw new InvalidDataException("Trusted native helper literal not balanced."); + return compact.ToString(); + } + static string BuildElevatedHelperCommand(string mode,string payload,string appRoot,string user) + { + if(mode!="Install" && mode!="Uninstall") + throw new InvalidDataException("Unexpected elevated sensor mode."); + string brokerHash=Sha256Resource("Payload.TaskbarMonitorSensorBroker.exe"); + string supervisorHash=Sha256Resource("Payload.TaskbarMonitorSensorSupervisor.exe"); + string backendZipHash=Sha256Resource("Payload.LibreHardwareMonitor.zip"); + if(brokerHash.Length!=64 || supervisorHash.Length!=64 || backendZipHash.Length!=64) + throw new InvalidDataException("Protected sensor resource hash unavailable."); + string packed; + using(Stream resource=Resource("Payload.TBME_Setup_Elevated_Helper.ps1")) + { + if(resource==null)throw new InvalidDataException("Trusted embedded sensor helper missing."); + using(StreamReader reader=new StreamReader(resource,Encoding.UTF8,true)) + { + string source=CompactTrustedHelperSource(reader.ReadToEnd()); + byte[] textBytes=Encoding.UTF8.GetBytes(source); + if(textBytes.Length<1000 || textBytes.Length>120000) + throw new InvalidDataException("Unexpected sensor helper source size."); + using(MemoryStream buffer=new MemoryStream()) + { + using(GZipStream zipper=new GZipStream(buffer,CompressionMode.Compress,true)) + zipper.Write(textBytes,0,textBytes.Length); + packed=Convert.ToBase64String(buffer.ToArray()); + } + } + } + // Every caller-supplied parameter is separately base64-encoded as data. + // In particular, a quoted Windows user/path cannot inject PowerShell syntax. + string script= + "$ErrorActionPreference='Stop'\n"+ + "$blob=[Convert]::FromBase64String('"+packed+"')\n"+ + "$ms=[IO.MemoryStream]::new($blob)\n"+ + "$gzip=[IO.Compression.GZipStream]::new($ms,[IO.Compression.CompressionMode]::Decompress)\n"+ + "$reader=[IO.StreamReader]::new($gzip,[Text.Encoding]::UTF8)\n"+ + "try{$code=$reader.ReadToEnd()}finally{$reader.Dispose();$gzip.Dispose();$ms.Dispose()}\n"+ + "$payload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(payload)+"'))\n"+ + "$app=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(appRoot)+"'))\n"+ + "$user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(user)+"'))\n"+ + "& ([ScriptBlock]::Create($code)) -Mode '"+mode+"' -PayloadDir $payload -AppRoot $app -UserId $user -ExpectedBrokerSHA256 '"+brokerHash+"' -ExpectedSupervisorSHA256 '"+supervisorHash+"' -ExpectedBackendZipSHA256 '"+backendZipHash+"'\n"; + string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + // Preserve the established UTF-16 EncodedCommand path when it fits. + // Windows Process creation caps the entire command line at 32,767 chars. + if(encoded.Length<=32000) + return "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; + // R304: the SAME in-memory, PE-embedded trusted helper can be transported + // as a constant PowerShell -Command string for long profile names. + // Variable caller input is only UTF-8/Base64 data; mode is whitelisted + // and resource digests are SHA-256 hex. No user-controlled quote or + // PowerShell tokens can enter this command source. Never use -File. + if(script.IndexOf('"')>=0 || script.IndexOf('\0')>=0) + throw new InvalidDataException("Unexpected quote or null in trusted helper bootstrap."); + string fallback="-NoProfile -NonInteractive -WindowStyle Hidden -Command \"& { "+script+" }\""; + // Leave margin for the executable path and OS quoting. A truly huge + // value must still fail closed instead of truncating or using Temp. + if(fallback.Length>30000) + throw new InvalidDataException("Trusted sensor helper command exceeds safe limit."); + return fallback; + } + + // R298: runas uses ShellExecuteEx, which searches the current directory, + // Windows and PATH for an unqualified executable. Pin the native OS host. + static string TrustedSystemPowerShellPath() + { + string system=Path.GetFullPath(Environment.SystemDirectory); + if(!Path.IsPathRooted(system) || !Directory.Exists(system)) + throw new InvalidOperationException("Trusted Windows system directory unavailable."); + string first=Path.Combine(system,"WindowsPowerShell"); + string folder=Path.Combine(first,"v1.0"); + string exe=Path.Combine(folder,"powershell.exe"); + if(!Directory.Exists(first) || !Directory.Exists(folder) || !File.Exists(exe)) + throw new InvalidOperationException("Trusted Windows PowerShell executable missing."); + foreach(string candidate in new string[]{system,first,folder,exe}) + { + if((File.GetAttributes(candidate)&FileAttributes.ReparsePoint)!=0) + throw new InvalidOperationException("Trusted PowerShell host is a reparse path."); + } + return exe; + } + + // Kept separate from Process.Start so the exact runas target can be + // inspected in disposable Windows without granting UAC or starting it. + static ProcessStartInfo BuildElevatedSensorProcessInfo(string mode,string payload,string appRoot,string user) { ProcessStartInfo psi=new ProcessStartInfo(); - psi.FileName="powershell.exe"; - psi.Arguments="-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \""+helper+"\" "+args; + psi.FileName=TrustedSystemPowerShellPath(); + psi.WorkingDirectory=Path.GetDirectoryName(psi.FileName); + psi.Arguments=BuildElevatedHelperCommand(mode,payload,appRoot,user); psi.UseShellExecute=true; psi.Verb="runas"; + return psi; + } + + static int ElevatedHelper(string mode,string payload,string appRoot,string user,int timeoutMs) + { + ProcessStartInfo psi=BuildElevatedSensorProcessInfo(mode,payload,appRoot,user); Process elevated=null; Exception launchError=null; @@ -150,33 +444,196 @@ static int ElevatedHelper(string helper,string args,int timeoutMs) return elevated.ExitCode; } + // R323: remove only shortcuts that still point at this product and retain + // their exact product arguments. A same-name user shortcut is not ours. + // This is a same-user guard, not an atomic defence against rename races. + static bool IsOwnedShortcut(string path,string expectedTarget,string expectedArguments) + { + if(String.IsNullOrWhiteSpace(path) || String.IsNullOrWhiteSpace(expectedTarget) || + !Path.IsPathRooted(path) || !Path.IsPathRooted(expectedTarget) || + !String.Equals(Path.GetExtension(path),".lnk",StringComparison.OrdinalIgnoreCase)) + return false; + string args=expectedArguments??""; + if(args!="" && args!="/repair-sensors" && args!="--startup-recovery") + return false; + string link=Path.GetFullPath(path); + string target=Path.GetFullPath(expectedTarget); + try + { + if(!File.Exists(link) || Directory.Exists(link) || + (File.GetAttributes(link)&FileAttributes.ReparsePoint)!=0) + return false; + long bytes=new FileInfo(link).Length; + if(bytes<1 || bytes>1024*1024)return false; + + Type shellType=Type.GetTypeFromProgID("WScript.Shell",true); + object shell=null; + object shortcut=null; + bool owned=false; + try + { + shell=Activator.CreateInstance(shellType); + shortcut=shell.GetType().InvokeMember("CreateShortcut", + BindingFlags.InvokeMethod,null,shell,new object[]{link}); + if(shortcut==null)return false; + Type shortcutType=shortcut.GetType(); + string actualTarget=shortcutType.InvokeMember("TargetPath", + BindingFlags.GetProperty,null,shortcut,new object[0]) as string; + string actualArguments=shortcutType.InvokeMember("Arguments", + BindingFlags.GetProperty,null,shortcut,new object[0]) as string; + if(!String.IsNullOrWhiteSpace(actualTarget) && Path.IsPathRooted(actualTarget)) + { + owned=String.Equals(Path.GetFullPath(actualTarget),target, + StringComparison.OrdinalIgnoreCase) && + String.Equals((actualArguments??"").Trim(),args,StringComparison.Ordinal); + } + } + finally + { + if(shortcut!=null && System.Runtime.InteropServices.Marshal.IsComObject(shortcut)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shortcut); + if(shell!=null && System.Runtime.InteropServices.Marshal.IsComObject(shell)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); + } + if(!owned || !File.Exists(link) || + (File.GetAttributes(link)&FileAttributes.ReparsePoint)!=0) + return false; + return true; + + } + catch + { + // A broken or foreign shortcut must never turn uninstall into + // deletion of user-owned content or an elevated fallback. + return false; + } + } + + static bool DeleteOwnedShortcut(string path,string expectedTarget,string expectedArguments) + { + if(!IsOwnedShortcut(path,expectedTarget,expectedArguments)) + return false; + try + { + File.Delete(Path.GetFullPath(path)); + return !File.Exists(path); + } + catch{return false;} + } + + // R333: lexical parent enumeration sees *all* occupied entries, including + // dangling reparse names that File.Exists/Directory.Exists can both miss. + // Match the exact leaf after enumeration; never follow a foreign link for + // ownership. Unexpected access/IO errors fail closed by propagating. + static bool ShortcutNameOccupied(string path) + { + if(String.IsNullOrWhiteSpace(path) || !Path.IsPathRooted(path)) + throw new InvalidDataException("Shortcut occupancy requires an absolute link path."); + string full=Path.GetFullPath(path); + string parent=Path.GetDirectoryName(full); + string leaf=Path.GetFileName(full); + if(String.IsNullOrWhiteSpace(parent) || String.IsNullOrWhiteSpace(leaf) || + !String.Equals(Path.GetExtension(leaf),".lnk",StringComparison.OrdinalIgnoreCase) || + leaf.IndexOfAny(Path.GetInvalidFileNameChars())>=0) + throw new InvalidDataException("Shortcut occupancy requires a literal .lnk leaf."); + try + { + // Nonrecursive, literal pattern, exact-case-insensitive comparison. + // Enumeration includes files, directories and dangling reparse links. + foreach(FileSystemInfo entry in new DirectoryInfo(parent).GetFileSystemInfos(leaf)) + if(String.Equals(entry.Name,leaf,StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + catch(DirectoryNotFoundException){return false;} + } + + // R324: the user may have an unrelated existing shortcut with this name. + // Existing owned shortcuts can be refreshed, but foreign ones are immutable. + static void AssertOwnedOrAbsentShortcut(string path,string target,string args) + { + if(ShortcutNameOccupied(path) && !IsOwnedShortcut(path,target,args)) + throw new IOException("FOREIGN_SHORTCUT_NAME_CONFLICT: refusing to overwrite another shortcut."); + } + + // No installation root exists at this point. No current shortcut may be + // attributed to this fresh installation, even if it claims our target. + static void AssertFreshShortcutNameUnused(string path) + { + if(ShortcutNameOccupied(path)) + throw new IOException("FOREIGN_SHORTCUT_NAME_CONFLICT: fresh install cannot replace an existing shortcut."); + } + + static void AssertFreshInstallShortcutPreflight(bool desktop,bool startup) + { + string programs=Environment.GetFolderPath(Environment.SpecialFolder.Programs); + AssertFreshShortcutNameUnused(Path.Combine(programs,Product+".lnk")); + AssertFreshShortcutNameUnused(Path.Combine(programs,Product+" - Repair Hardware Sensors.lnk")); + if(desktop) + AssertFreshShortcutNameUnused(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),Product+".lnk")); + if(startup) + AssertFreshShortcutNameUnused(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Startup), + "Taskbar Monitor Enhanced Startup Recovery.lnk")); + } + static void Shortcut(string path,string target) { Shortcut(path,target,"","Live system monitor integrated into the Windows taskbar"); } + // R318: WScript.Shell COM is invoked directly inside the Setup PE. + // Never generate or execute writable Temp scripts to create shortcuts. static void Shortcut(string path,string target,string arguments,string description) { - string temp=Path.Combine(Path.GetTempPath(),"tbme_shortcut_"+Guid.NewGuid().ToString("N")+".ps1"); - string text= - "$ErrorActionPreference='Stop'\r\n"+ - "$w=New-Object -ComObject WScript.Shell\r\n"+ - "$s=$w.CreateShortcut('"+path.Replace("'","''")+"')\r\n"+ - "$s.TargetPath='"+target.Replace("'","''")+"'\r\n"+ - "$s.Arguments='"+arguments.Replace("'","''")+"'\r\n"+ - "$s.WorkingDirectory='"+AppRoot.Replace("'","''")+"'\r\n"+ - "$s.IconLocation='"+AppExe.Replace("'","''")+",0'\r\n"+ - "$s.Description='"+description.Replace("'","''")+"'\r\n"+ - "$s.WindowStyle=1\r\n$s.Save()\r\n"; - File.WriteAllText(temp,text,Encoding.UTF8); - try{ - ProcessStartInfo psi=new ProcessStartInfo("powershell.exe","-NoProfile -ExecutionPolicy Bypass -File \""+temp+"\""); - psi.UseShellExecute=false;psi.CreateNoWindow=true; - Process p=Process.Start(psi); - if(p==null || !p.WaitForExit(15000) || p.ExitCode!=0) - throw new InvalidOperationException("Shortcut creation failed."); + if(String.IsNullOrWhiteSpace(path) || String.IsNullOrWhiteSpace(target) || + !Path.IsPathRooted(path) || !Path.IsPathRooted(target) || + !String.Equals(Path.GetExtension(path),".lnk",StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Shortcut requires absolute .lnk and target paths."); + string linkPath=Path.GetFullPath(path); + string targetPath=Path.GetFullPath(target); + string linkDirectory=Path.GetDirectoryName(linkPath); + if(String.IsNullOrWhiteSpace(linkDirectory) || !Directory.Exists(linkDirectory)) + throw new DirectoryNotFoundException("Shortcut directory is not available."); + // Block an existing malicious linked destination before opening it; + // user-local directory reparse races remain a separate security gate. + if(File.Exists(linkPath) && (File.GetAttributes(linkPath)&FileAttributes.ReparsePoint)!=0) + throw new IOException("Shortcut destination is a reparse point."); + // Refuse an unexpected same-name shortcut before COM Save can overwrite it. + AssertOwnedOrAbsentShortcut(linkPath,targetPath,arguments); + Type shellType=Type.GetTypeFromProgID("WScript.Shell",true); + object shell=null; + object shortcut=null; + try + { + shell=Activator.CreateInstance(shellType); + shortcut=shell.GetType().InvokeMember("CreateShortcut", + BindingFlags.InvokeMethod,null,shell,new object[]{linkPath}); + if(shortcut==null) + throw new InvalidOperationException("Windows Script Host did not create a shortcut object."); + Type shortcutType=shortcut.GetType(); + BindingFlags set=BindingFlags.SetProperty; + shortcutType.InvokeMember("TargetPath",set,null,shortcut,new object[]{targetPath}); + shortcutType.InvokeMember("Arguments",set,null,shortcut,new object[]{arguments??""}); + shortcutType.InvokeMember("WorkingDirectory",set,null,shortcut,new object[]{AppRoot}); + shortcutType.InvokeMember("IconLocation",set,null,shortcut,new object[]{AppExe+",0"}); + shortcutType.InvokeMember("Description",set,null,shortcut,new object[]{description??""}); + shortcutType.InvokeMember("WindowStyle",set,null,shortcut,new object[]{1}); + // Recheck immediately before COM Save: fail closed if another + // same-name shortcut appeared while its object was prepared. + AssertOwnedOrAbsentShortcut(linkPath,targetPath,arguments); + shortcutType.InvokeMember("Save",BindingFlags.InvokeMethod,null,shortcut,new object[0]); + if(!File.Exists(linkPath)) + throw new IOException("Windows Script Host did not persist the shortcut."); + } + finally + { + if(shortcut!=null && System.Runtime.InteropServices.Marshal.IsComObject(shortcut)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shortcut); + if(shell!=null && System.Runtime.InteropServices.Marshal.IsComObject(shell)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); } - finally{try{File.Delete(temp);}catch{}} } static string Sha256File(string path) @@ -191,6 +648,81 @@ static string Sha256File(string path) } } + // R321: hold a Windows read-only, no-write/no-delete sharing handle + // over the hash check AND Process.Start. A writable user-local executable + // cannot be replaced by a second handle during this bounded interval. + // This is not a proof against hostile parent-directory rename races. + static FileStream OpenPinnedVerifiedMainForLaunch( + string candidate,string expectedRoot,string embeddedMainSHA) + { + if(String.IsNullOrWhiteSpace(candidate) || + String.IsNullOrWhiteSpace(expectedRoot) || + !Path.IsPathRooted(candidate) || !Path.IsPathRooted(expectedRoot) || + !Regex.IsMatch(embeddedMainSHA??"",@"^[0-9A-F]{64}$")) + throw new InvalidDataException("Installed Main launch identity is incomplete."); + string root=Path.GetFullPath(expectedRoot).TrimEnd(Path.DirectorySeparatorChar); + string path=Path.GetFullPath(candidate); + string expected=Path.Combine(root,"TaskbarMonitorEnhanced.exe"); + if(root.StartsWith(@"\\",StringComparison.Ordinal) || + path.StartsWith(@"\\",StringComparison.Ordinal) || + !String.Equals(path,expected,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Installed Main launch path escaped the trusted root."); + CheckRootAncestorsNoReparse(path); + FileAttributes attrs=File.GetAttributes(path); + if((attrs&FileAttributes.ReparsePoint)!=0 || (attrs&FileAttributes.Directory)!=0) + throw new InvalidDataException("Installed Main launch executable is linked or not a file."); + FileStream pinned=null; + try + { + // Open sharing READ only; forbid unrelated write/delete opens + // while the Setup process starts the exact validated Main. + pinned=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.Read); + if(pinned.Length<1024 || pinned.Length>134217728) + throw new InvalidDataException("Installed Main executable length out of bounds."); + using(SHA256 hasher=SHA256.Create()) + { + byte[] hash=hasher.ComputeHash(pinned); + StringBuilder result=new StringBuilder(); + foreach(byte item in hash)result.Append(item.ToString("X2")); + if(!String.Equals(result.ToString(),embeddedMainSHA,StringComparison.Ordinal)) + throw new InvalidDataException("Installed Main does not match the immutable Setup PE resource."); + } + pinned.Position=0; + return pinned; + } + catch + { + if(pinned!=null)pinned.Dispose(); + throw; + } + } + + // R326: .NET Framework defaults UseShellExecute=true for Process.Start(path). + // Explicitly create the exact SHA-verified Main as a process with no + // arguments, no shell association, and no implicit executable search. + // Pinned file bytes are guarded by OpenPinnedVerifiedMainForLaunch. + // User-writable ancestor directory swap races remain a distinct gate. + static ProcessStartInfo BuildPinnedMainProcessInfo(FileStream pinned,string expectedRoot) + { + if(pinned==null || !pinned.CanRead || String.IsNullOrWhiteSpace(expectedRoot) || + !Path.IsPathRooted(expectedRoot)) + throw new InvalidDataException("Pinned Main process identity is unavailable."); + string root=Path.GetFullPath(expectedRoot).TrimEnd(Path.DirectorySeparatorChar); + string path=Path.GetFullPath(pinned.Name); + if(root.StartsWith(@"\\",StringComparison.Ordinal) || + path.StartsWith(@"\\",StringComparison.Ordinal) || + !String.Equals(path,Path.Combine(root,"TaskbarMonitorEnhanced.exe"), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Pinned Main process path escaped its installation root."); + ProcessStartInfo psi=new ProcessStartInfo(); + psi.FileName=path; + // Keep the preceding Setup current-directory behavior; do not pass + // a profile-controlled WorkingDirectory to CreateProcess. + psi.Arguments=String.Empty; + psi.UseShellExecute=false; + psi.ErrorDialog=false; + return psi; + } static string Sha256Resource(string resourceName) { using(Stream s=Resource(resourceName)) @@ -228,8 +760,8 @@ static void WriteInstallState(SensorOutcome sensorOutcome) string sensorMode=sensorOutcome==null?"":(sensorOutcome.LayerMode??""); string json="{\r\n"+ " \"App\": \"Taskbar Monitor Enhanced\",\r\n"+ - " \"Version\": \"1.7.2\",\r\n"+ - " \"PublicVersion\": \"1.7.2\",\r\n"+ + " \"Version\": \"1.7.8\",\r\n"+ + " \"PublicVersion\": \"1.7.8\",\r\n"+ " \"InternalRuntimeBaseline\": \"V1_1_2_R21_PRODUCTION_HARDENING\",\r\n"+ " \"SensorSupervisor\": \"V1_3_0_R33_STORAGE_COMPLETION_HARDENING\",\r\n"+ " \"SensorLayerStatus\": \""+sensorStatus.Replace("\\","\\\\").Replace("\"","\\\"")+"\",\r\n"+ @@ -252,18 +784,16 @@ static void WriteInstallState(SensorOutcome sensorOutcome) static string PrepareSensorPayload() { - string payload=Path.Combine(Path.GetTempPath(),"tbme_setup_"+Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(payload); + string payload=CreatePrivateSensorPayloadDirectory("tbme_setup_"); Extract("Payload.TaskbarMonitorSensorBroker.exe",Path.Combine(payload,"TaskbarMonitorSensorBroker.exe")); Extract("Payload.TaskbarMonitorSensorSupervisor.exe",Path.Combine(payload,"TaskbarMonitorSensorSupervisor.exe")); - // The official PawnIO binary is separately licensed. Public OSS Setup never embeds it. - // A previously installed PawnIO 2.2.0 may be reused; otherwise the sensor helper degrades - // safely and offers upstream manual installation, without silent driver downloads. - using(Stream pawn=Resource("Payload.PawnIO_setup.exe")){ - if(pawn!=null && pawn.Length>0) - Extract("Payload.PawnIO_setup.exe",Path.Combine(payload,"PawnIO_setup.exe")); - } - Extract("Payload.TBME_Setup_Elevated_Helper.ps1",Path.Combine(payload,"TBME_Setup_Elevated_Helper.ps1")); + // This user-writable staging file is bound to the PE embedded ZIP hash + // before any elevated service changes; it is deleted with this payload. + Extract("Payload.LibreHardwareMonitor.zip",Path.Combine(payload,"LibreHardwareMonitor.zip")); + // R297: never stage or elevate a third-party executable in user-writable + // Temp, even if an optional private PE contains a PawnIO resource. + // Reuse compatible preinstalled PawnIO, otherwise require separate + // official installation with explicit owner consent. return payload; } @@ -326,12 +856,44 @@ static bool JsonBool(string json,string key) return p+4<=json.Length && String.Equals(json.Substring(p,4),"true",StringComparison.OrdinalIgnoreCase); } + // R332: reuse of an optional elevated sensor layer is allowed only + // under the OS known ProgramFiles and verified same-user app root. + // Refuse observed reparse points before reusing protected hashes or + // user profile health state. These checks are path-based, not atomic. + static string ValidatedSensorReuseRoot(string knownProgramFiles,string knownLocal,string knownProfile) + { + if(String.IsNullOrWhiteSpace(knownProgramFiles) || + !Path.IsPathRooted(knownProgramFiles)) + throw new InvalidDataException("Trusted ProgramFiles root unavailable."); + string pf=Path.GetFullPath(knownProgramFiles).TrimEnd(Path.DirectorySeparatorChar); + if(pf.StartsWith(@"\\",StringComparison.Ordinal) || + !Regex.IsMatch(pf,@"^[A-Za-z]:\\") || + String.Equals(pf,Path.GetPathRoot(pf),StringComparison.OrdinalIgnoreCase) || + pf.Length>512) + throw new InvalidDataException("Protected sensor reuse refuses UNC or root-only ProgramFiles."); + string userRoot=CanonicalAppRootFromKnownFolders(knownLocal,knownProfile); + string sensorRoot=Path.GetFullPath(Path.Combine(pf,AppFolder,"SensorBroker")); + string expectedParent=Path.GetFullPath(Path.Combine(pf,AppFolder)); + if(!String.Equals(Path.GetDirectoryName(sensorRoot),expectedParent, + StringComparison.OrdinalIgnoreCase) || + sensorRoot.Length>1024) + throw new InvalidDataException("Protected sensor reuse escaped ProgramFiles."); + CheckRootAncestorsNoReparse(sensorRoot); + CheckRootAncestorsNoReparse(Path.Combine(sensorRoot,"TaskbarMonitorSensorBroker.exe")); + CheckRootAncestorsNoReparse(Path.Combine(sensorRoot,"TaskbarMonitorSensorSupervisor.exe")); + CheckRootAncestorsNoReparse(Path.Combine(userRoot,"sensor_supervisor_state.json")); + return sensorRoot; + } + static bool TryReuseCompatibleSensorLayer(out SensorOutcome outcome) { outcome=null; try { - string sensorRoot=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),"TaskbarMonitorEnhanced","SensorBroker"); + string sensorRoot=ValidatedSensorReuseRoot( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); string broker=Path.Combine(sensorRoot,"TaskbarMonitorSensorBroker.exe"); string supervisor=Path.Combine(sensorRoot,"TaskbarMonitorSensorSupervisor.exe"); string statePath=Path.Combine(AppRoot,"sensor_supervisor_state.json"); @@ -392,13 +954,10 @@ static SensorOutcome InstallSensorLayer() Directory.CreateDirectory(Path.Combine(AppRoot,"Logs")); try{File.Delete(SensorResultPath);}catch{} payload=PrepareSensorPayload(); - string helper=Path.Combine(payload,"TBME_Setup_Elevated_Helper.ps1"); string user=WindowsIdentity.GetCurrent().Name; int helperExit; try{ - helperExit=ElevatedHelper(helper, - "-Mode Install -PayloadDir \""+payload+"\" -AppRoot \""+AppRoot+"\" -UserId \""+user+"\"", - 110000); + helperExit=ElevatedHelper("Install",payload,AppRoot,user,110000); }catch(Exception ex){ outcome.Status="DEGRADED"; outcome.Message="The application installed successfully. The protected sensor supervisor did not report ready before Setup finished. "+ex.Message+" It will continue starting in the background; protected temperature fields may briefly show N/A."; @@ -445,12 +1004,14 @@ static bool IsCurrentProcessElevated() static SensorOutcome Install(bool desktop,bool startup) { if(!Environment.Is64BitOperatingSystem) - throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.2 requires 64-bit Windows."); + throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.8 requires 64-bit Windows."); // R114: prevent fresh-install code from ever overwriting an installed // product or user-owned files. Existing roots must use /update. if(Directory.Exists(AppRoot) || ExistingRegistered()) throw new InvalidOperationException("Existing installation detected; use the guarded in-place updater."); + // Ownership conflicts are detected before touching Main, processes or AppRoot. + AssertFreshInstallShortcutPreflight(desktop,startup); StopProcess("TaskbarMonitorEnhanced"); Directory.CreateDirectory(AppRoot); @@ -460,7 +1021,7 @@ static SensorOutcome Install(bool desktop,bool startup) Extract("Payload.TaskbarMonitorEnhanced.exe",AppExe); Extract("Payload.TaskbarMonitorEnhanced.exe.config",AppExe+".config"); Extract("Payload.TaskbarMonitorEnhanced.ico",Path.Combine(AppRoot,"TaskbarMonitorEnhanced.ico")); - Extract("Payload.TaskbarMonitorEnhanced.cs",Path.Combine(AppRoot,"TaskbarMonitorEnhanced.cs")); + // R255: avoid a redundant root source that breaks same-version maintenance. Extract("Payload.TaskbarMonitorEnhanced.cs",Path.Combine(AppRoot,"Source","TaskbarMonitorEnhanced.cs")); Extract("Payload.StudioThemes.cs",Path.Combine(AppRoot,"Source","StudioThemes.cs")); Extract("Payload.WallpaperStudio.cs",Path.Combine(AppRoot,"Source","WallpaperStudio.cs")); @@ -475,18 +1036,14 @@ static SensorOutcome Install(bool desktop,bool startup) string[] docs=new string[]{ "LICENSE","README.md","AUTHORS.md","COPYRIGHT_AND_ATTRIBUTION.md", "AI_ASSISTED_DEVELOPMENT.md","THIRD_PARTY_NOTICES.md", - "RELEASE_NOTES_v1.7.2.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" + "RELEASE_NOTES_v1.7.8.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" }; foreach(string doc in docs) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); string backendRoot=Path.Combine(AppRoot,"SensorBackend","LibreHardwareMonitor-0.9.6"); - if(Directory.Exists(backendRoot))Directory.Delete(backendRoot,true); - Directory.CreateDirectory(backendRoot); - string lhmZip=Path.Combine(Path.GetTempPath(),"tbme_lhm_"+Guid.NewGuid().ToString("N")+".zip"); - Extract("Payload.LibreHardwareMonitor.zip",lhmZip); - ZipFile.ExtractToDirectory(lhmZip,backendRoot); - try{File.Delete(lhmZip);}catch{} + using(Stream backendZip=Resource("Payload.LibreHardwareMonitor.zip")) + ExtractBackendFromArchive(backendZip,backendRoot); WriteBackendState(backendRoot); SensorOutcome sensorOutcome; @@ -502,7 +1059,7 @@ static SensorOutcome Install(bool desktop,bool startup) Shortcut(startLink,AppExe); Shortcut(repairLink,uninstaller,"/repair-sensors","Repair or retry Taskbar Monitor Enhanced hardware sensors"); if(desktop)Shortcut(desktopLink,AppExe); - else try{File.Delete(desktopLink);}catch{} + // Fresh install with desktop disabled does not touch any existing link. string recoveryLink=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"); using(RegistryKey run=Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run")){ @@ -510,7 +1067,7 @@ static SensorOutcome Install(bool desktop,bool startup) else run.DeleteValue("TaskbarMonitorEnhanced",false); } if(startup)Shortcut(recoveryLink,AppExe,"--startup-recovery","Delayed recovery launcher for Taskbar Monitor Enhanced Start-with-Windows."); - else try{File.Delete(recoveryLink);}catch{} + // Fresh install with startup disabled does not touch any existing link. using(RegistryKey key=Registry.CurrentUser.CreateSubKey(UninstallKey)){ key.SetValue("DisplayName",Product); @@ -532,7 +1089,21 @@ static SensorOutcome Install(bool desktop,bool startup) { try { - Process.Start(AppExe); + // The read-sharing handle pins the same trusted bytes over + // validation and the subsequent process creation. + using(FileStream pinned=OpenPinnedVerifiedMainForLaunch( + AppExe,AppRoot,Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"))) + { + // R326: use CreateProcess, not ShellExecute's file/verb + // handling. The executable path comes from the SHA-pinned + // open file; no user-supplied command-line arguments exist. + ProcessStartInfo direct=BuildPinnedMainProcessInfo(pinned,AppRoot); + using(Process started=Process.Start(direct)) + { + if(started==null) + throw new IOException("Pinned Main could not be started."); + } + } MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"; } catch @@ -544,6 +1115,205 @@ static SensorOutcome Install(bool desktop,bool startup) return sensorOutcome; } + // R317: defer deletion until this unelevated Setup process exits. + // Do not write executable scripts into mutable Temp and never run cmd.exe. + // The entire PowerShell source is an immutable Setup PE string; all paths + // enter only as Base64 UTF-8 data, never as PowerShell syntax. + static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int parentPid,long parentStartUtcTicks) + { + string canonical=AppRoot; + if(String.IsNullOrWhiteSpace(root) || + !String.Equals(Path.GetFullPath(root),canonical,StringComparison.OrdinalIgnoreCase) || + parentPid<=0 || parentStartUtcTicks<=0) + throw new InvalidDataException("Uninstall cleanup requires the exact owner root and parent identity."); + string sid=WindowsIdentity.GetCurrent().User.Value; + if(!Regex.IsMatch(sid,@"^S-1-5-21-(?:\d+-){3}\d+$")) + throw new InvalidDataException("Uninstall cleanup refuses a noninteractive token."); + string rootBlob=Base64Utf8Argument(canonical); + string script=@"Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__ROOT_B64__')) +$expectedSid='__OWNER_SID__' +$expectedPid=[int]__PROCESS_ID__ +$expectedStart=[long]__START_TICKS__ +if([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -cne $expectedSid){ + throw 'TBME_CLEANUP_WRONG_EFFECTIVE_TOKEN' +} +$profile=[IO.Path]::GetFullPath([Environment]::GetFolderPath('UserProfile')).TrimEnd('\') +$local=[IO.Path]::GetFullPath([Environment]::GetFolderPath('LocalApplicationData')).TrimEnd('\') +$expectedLocal=[IO.Path]::GetFullPath([IO.Path]::Combine($profile,'AppData','Local')).TrimEnd('\') +$expectedRoot=[IO.Path]::GetFullPath([IO.Path]::Combine($local,'TaskbarMonitorEnhanced')) +if($profile.StartsWith('\\') -or $local.StartsWith('\\') -or + -not [string]::Equals($local,$expectedLocal,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([IO.Path]::GetFullPath($root),$expectedRoot,[StringComparison]::OrdinalIgnoreCase)){ + throw 'TBME_CLEANUP_ROOT_NOT_CANONICAL' +} +try{ + $parent=[Diagnostics.Process]::GetProcessById($expectedPid) + try{ + if($parent.StartTime.ToUniversalTime().Ticks -eq $expectedStart -and + -not $parent.WaitForExit(300000)){ + throw 'TBME_CLEANUP_PARENT_STILL_RUNNING' + } + }finally{$parent.Dispose()} +}catch [ArgumentException]{ + # Parent already exited before cleanup began. +}catch [InvalidOperationException]{ + # Parent exited between process lookup and StartTime/WaitForExit. +} +if(-not [IO.Directory]::Exists($expectedRoot)){exit 0} + +# Pin exact native directory handles while removing known owned entries. +Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Text; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public sealed class TBMEDeferredDirectoryLeaseSet : IDisposable { + const uint LIST=1,DELETE=65536,SHARE_READ_WRITE=3,OPEN_EXISTING=3; + const uint BACKUP=0x02000000,OPEN_REPARSE=0x00200000,DIR=0x10,REPARSE=0x400; + [StructLayout(LayoutKind.Sequential)] + struct TAG {public uint Attributes;public uint ReparseTag;} + [StructLayout(LayoutKind.Sequential)]struct DISP {public byte DeleteFile;} + [DllImport(""kernel32.dll"",SetLastError=true)] + static extern bool SetFileInformationByHandle(SafeFileHandle h,int cls,ref DISP info,uint size); + [DllImport(""kernel32.dll"",CharSet=CharSet.Unicode,EntryPoint=""CreateFileW"",SetLastError=true)] + static extern SafeFileHandle Create(string p,uint access,uint sharing,IntPtr sec,uint create,uint flags,IntPtr template); + [DllImport(""kernel32.dll"",SetLastError=true)] + static extern bool GetFileInformationByHandleEx(SafeFileHandle file,int kind,out TAG info,uint size); + [DllImport(""kernel32.dll"",CharSet=CharSet.Unicode,EntryPoint=""GetFinalPathNameByHandleW"",SetLastError=true)] + static extern uint Final(SafeFileHandle file,StringBuilder path,uint size,uint flags); + readonly Dictionary pins=new Dictionary(StringComparer.OrdinalIgnoreCase); + public void Pin(string path){Pin(path,false);} + public void Pin(string path,bool canDelete){ + if(String.IsNullOrWhiteSpace(path))throw new IOException(""R355_PIN_EMPTY_DIRECTORY""); + string expected=Path.GetFullPath(path).TrimEnd('\\'); + if(pins.ContainsKey(expected))return; + SafeFileHandle h=Create(expected,LIST|(canDelete?DELETE:0),SHARE_READ_WRITE,IntPtr.Zero,OPEN_EXISTING,BACKUP|OPEN_REPARSE,IntPtr.Zero); + if(h.IsInvalid){int e=Marshal.GetLastWin32Error();h.Dispose();throw new IOException(""R355_PIN_CREATEFILE_WIN32_""+e);} + try{ + TAG t; + if(!GetFileInformationByHandleEx(h,9,out t,(uint)Marshal.SizeOf(typeof(TAG)))) + throw new IOException(""R355_PIN_TAG_WIN32_""+Marshal.GetLastWin32Error()); + if((t.Attributes&DIR)==0||(t.Attributes&REPARSE)!=0) + throw new IOException(""R355_PIN_NON_DIRECTORY_OR_REPARSE""); + StringBuilder b=new StringBuilder(4096); + uint n=Final(h,b,(uint)b.Capacity,0); + if(n==0||n>=b.Capacity)throw new IOException(""R355_PIN_FINAL_PATH_WIN32_""+Marshal.GetLastWin32Error()); + string actual=b.ToString(); + if(actual.StartsWith(@""\\?\"",StringComparison.Ordinal))actual=actual.Substring(4); + if(!String.Equals(actual.TrimEnd('\\'),expected,StringComparison.OrdinalIgnoreCase)) + throw new IOException(""R355_PIN_REDIRECTED_DIRECTORY""); + pins.Add(expected,h); + }catch{h.Dispose();throw;} + } + public void Release(string path){ + string p=Path.GetFullPath(path).TrimEnd('\\'); + SafeFileHandle h; + if(!pins.TryGetValue(p,out h))throw new IOException(""R355_PIN_RELEASE_UNKNOWN_DIRECTORY""); + pins.Remove(p);h.Dispose(); + } + public void DeletePinned(string path){ + string p=Path.GetFullPath(path).TrimEnd('\\'); + SafeFileHandle h; + if(!pins.TryGetValue(p,out h))throw new IOException(""R356_PIN_NOT_FOUND""); + DISP d=new DISP();d.DeleteFile=1; + if(!SetFileInformationByHandle(h,4,ref d,(uint)Marshal.SizeOf(typeof(DISP)))) + throw new IOException(""R356_PIN_DELETE_WIN32_""+Marshal.GetLastWin32Error()); + pins.Remove(p);h.Dispose(); + } + public void Dispose(){ + foreach(SafeFileHandle h in pins.Values)h.Dispose(); + pins.Clear(); + } +} +'@ +# Reject reparse points before inventory. +$cursor=$expectedRoot +while(-not [string]::IsNullOrEmpty($cursor)){ + if(([IO.File]::GetAttributes($cursor) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'TBME_CLEANUP_ANCESTOR_REPARSE' + } + $parent=[IO.Path]::GetDirectoryName($cursor) + if([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor){break} + $cursor=$parent +} +# Inventory exact user-owned entries only. +$prefix=[IO.Path]::GetFullPath($expectedRoot).TrimEnd('\')+'\' +$stack=[Collections.Generic.List[string]]::new() +$dirs=[Collections.Generic.List[string]]::new() +$files=[Collections.Generic.List[string]]::new() +$stack.Add($expectedRoot) +$lease=[TBMEDeferredDirectoryLeaseSet]::new() +try{ +foreach($pinPath in @($profile,(Join-Path $profile 'AppData'),$local,$expectedRoot)){ + $lease.Pin($pinPath,($pinPath -ceq $expectedRoot)) +} +$members=0 +while($stack.Count -gt 0){ + $dir=$stack[$stack.Count-1] + $stack.RemoveAt($stack.Count-1) + $attrs=[IO.File]::GetAttributes($dir) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ($attrs -band [IO.FileAttributes]::Directory) -eq 0){ + throw 'TBME_CLEANUP_CHANGED_DIRECTORY' + } + $lease.Pin($dir,$true) + $dirs.Add($dir) + foreach($entry in [IO.Directory]::GetFileSystemEntries($dir)){ + $members++ + if($members -gt 200000){throw 'TBME_CLEANUP_EXCESSIVE_ENTRY_COUNT'} + $full=[IO.Path]::GetFullPath($entry) + if(-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){ + throw 'TBME_CLEANUP_PATH_ESCAPED_OWNER_ROOT' + } + $attrs=[IO.File]::GetAttributes($full) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'TBME_CLEANUP_CHILD_REPARSE' + } + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + $stack.Add($full) + }else{ + $files.Add($full) + } + } +} +# Delete known files and then delete pinned empty directories by handle. +foreach($file in $files){ + $attrs=[IO.File]::GetAttributes($file) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + throw 'TBME_CLEANUP_CHANGED_FILE' + } + [IO.File]::Delete($file) +} +foreach($dir in ($dirs|Sort-Object Length -Descending)){ + if(@([IO.Directory]::GetFileSystemEntries($dir)).Count -ne 0){ + throw 'TBME_CLEANUP_UNEXPECTED_MEMBER' + } + $lease.DeletePinned($dir) +} +}finally{$lease.Dispose()} +"; + script=script.Replace("__ROOT_B64__",rootBlob) + .Replace("__OWNER_SID__",sid) + .Replace("__PROCESS_ID__",parentPid.ToString(CultureInfo.InvariantCulture)) + .Replace("__START_TICKS__",parentStartUtcTicks.ToString(CultureInfo.InvariantCulture)); + string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + if(encoded.Length>22000 || !Regex.IsMatch(encoded,@"^[A-Za-z0-9+/=]+$")) + throw new InvalidDataException("Uninstall cleanup command is not a bounded Base64 transport."); + ProcessStartInfo psi=new ProcessStartInfo(); + psi.FileName=TrustedSystemPowerShellPath(); + psi.Arguments="-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; + psi.WorkingDirectory=Environment.SystemDirectory; + psi.UseShellExecute=false; + psi.CreateNoWindow=true; + psi.WindowStyle=ProcessWindowStyle.Hidden; + return psi; + } + static void Uninstall(bool quiet) { if(!quiet){ @@ -555,40 +1325,37 @@ static void Uninstall(bool quiet) StopProcess("TaskbarMonitorEnhanced"); - string payload=Path.Combine(Path.GetTempPath(),"tbme_uninstall_"+Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(payload); - string helper=Path.Combine(payload,"TBME_Setup_Elevated_Helper.ps1"); - Extract("Payload.TBME_Setup_Elevated_Helper.ps1",helper); + string payload=CreatePrivateSensorPayloadDirectory("tbme_uninstall_"); string user=WindowsIdentity.GetCurrent().Name; - int helperExit=ElevatedHelper(helper, - "-Mode Uninstall -PayloadDir \""+payload+"\" -AppRoot \""+AppRoot+"\" -UserId \""+user+"\"", - 90000); + int helperExit=ElevatedHelper("Uninstall",payload,AppRoot,user,90000); if(helperExit!=0) throw new InvalidOperationException("Protected sensor removal failed. Exit code: "+helperExit); - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),Product+".lnk"));}catch{} - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+".lnk"));}catch{} - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+" - Repair Hardware Sensors.lnk"));}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),Product+".lnk"),AppExe,"");}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+".lnk"),AppExe,"");}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+" - Repair Hardware Sensors.lnk"),Path.Combine(AppRoot,"Uninstall.exe"),"/repair-sensors");}catch{} using(RegistryKey run=Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run")) run.DeleteValue("TaskbarMonitorEnhanced",false); - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"));}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"),AppExe,"--startup-recovery");}catch{} Registry.CurrentUser.DeleteSubKeyTree(UninstallKey,false); - string cleanup=Path.Combine(Path.GetTempPath(),"tbme_cleanup_"+Guid.NewGuid().ToString("N")+".cmd"); - File.WriteAllText(cleanup, - "@echo off\r\nping 127.0.0.1 -n 4 >nul\r\nrmdir /s /q \""+AppRoot+"\"\r\ndel /q \"%~f0\"\r\n", - Encoding.ASCII); - ProcessStartInfo psi=new ProcessStartInfo("cmd.exe","/c \""+cleanup+"\""); - psi.UseShellExecute=false;psi.CreateNoWindow=true; - // R108: background cmd must not inherit AppRoot as its working directory. - // Otherwise its rmdir of AppRoot can fail although Uninstall.exe exits zero. - psi.WorkingDirectory=Path.GetTempPath(); - Process.Start(psi); - + // Wait for the user to close the dialog before launching cleanup; + // then the cleanup process waits for this parent to exit. if(!quiet) - MessageBox.Show(Product+" was removed.","Uninstall complete",MessageBoxButtons.OK,MessageBoxIcon.Information); + MessageBox.Show(Product+" uninstall steps completed. Local files will be removed after this window closes.","Uninstall complete",MessageBoxButtons.OK,MessageBoxIcon.Information); + using(Process self=Process.GetCurrentProcess()) + { + ProcessStartInfo info=BuildDeferredUninstallCleanupProcessInfo( + AppRoot,self.Id,self.StartTime.ToUniversalTime().Ticks); + using(Process delayed=Process.Start(info)) + { + if(delayed==null) + throw new IOException("Trusted deferred uninstall cleanup did not start."); + } + } + } static int Verify(string path) @@ -619,7 +1386,7 @@ static int Verify(string path) } if(!String.IsNullOrEmpty(path)){ string json="{\"Status\":\"PASS\",\"Resources\":"+(RequiredResources.Length+(pawnBundled?1:0))+ - ",\"Version\":\"1.7.2\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ + ",\"Version\":\"1.7.8\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ ",\"SensorArchitecture\":\"R21_PROCESS_ISOLATED\",\"SensorUpgradePolicy\":\"EXACT_R33_SUPERVISOR_R21_PROTOCOL\""+ ",\"PawnIOBundled\":"+(pawnBundled?"true":"false")+",\"EmbeddedMainSHA256\":\""+embeddedMainSHA256+"\",\"EmbeddedMainBytes\":"+embeddedMainBytes+"}"; File.WriteAllText(path,json,Encoding.UTF8); @@ -658,7 +1425,7 @@ public SetupForm() Icon=Icon.ExtractAssociatedIcon(Application.ExecutablePath); Label title=new Label(); - title.Text=Product+" 1.7.2"; + title.Text=Product+" 1.7.8"; title.Font=new Font(Font.FontFamily,18,FontStyle.Bold); title.Left=28;title.Top=22;title.AutoSize=true;Controls.Add(title); @@ -693,12 +1460,12 @@ public SetupForm() progress.Visible=false; status.Text="Installation completed."; if(outcome.IsHealthy){ - MessageBox.Show(Product+" 1.7.2 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", + MessageBox.Show(Product+" 1.7.8 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", "Setup complete",MessageBoxButtons.OK,MessageBoxIcon.Information); }else{ string extra=outcome.RebootRequired ? "\r\n\r\nRestart Windows, then use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors if needed." : "\r\n\r\nThe application is installed and usable. The sensor supervisor continues in the background. If protected CPU/GPU/storage telemetry is still unavailable after a short wait or restart, use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors."; - MessageBox.Show(Product+" 1.7.2 was installed successfully.\r\n\r\n"+outcome.Message+extra, + MessageBox.Show(Product+" 1.7.8 was installed successfully.\r\n\r\n"+outcome.Message+extra, "Setup complete - sensor warning",MessageBoxButtons.OK,MessageBoxIcon.Warning); } Close(); @@ -728,39 +1495,6 @@ static int Main(string[] args) } } - // R129: diagnostic-only, strictly on short-lived GitHub-hosted Windows. - // No user PC logging, no fallback writes, no overwrite, no installer bypass. - static void WriteHostedUpgradeFailure(Exception ex) - { - try - { - if(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")!="true" || - Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT")!="github-hosted" || - Environment.GetEnvironmentVariable("RUNNER_OS")!="Windows" || - !Regex.IsMatch(Environment.GetEnvironmentVariable("GITHUB_SHA")??"","^[0-9a-f]{40}$")) - return; - string phase=Environment.GetEnvironmentVariable("TBME_UPDATE_DIAGNOSTIC_PHASE")??""; - if(phase!="NEGATIVE_USER_EDIT" && phase!="POSITIVE_OFFICIAL_STABLE" && - phase!="IDEMPOTENT" && phase!="DOWNGRADE")return; - string temp=Environment.GetEnvironmentVariable("RUNNER_TEMP")??""; - if(String.IsNullOrWhiteSpace(temp))return; - string evidence=Path.Combine(Path.GetFullPath(temp),"TBME_R126_STABLE_UPDATE"); - if(!Directory.Exists(evidence))return; - string file=Path.Combine(evidence,"R129_DIAG_"+phase+".txt"); - using(FileStream output=new FileStream(file,FileMode.CreateNew,FileAccess.Write,FileShare.None)) - using(StreamWriter writer=new StreamWriter(output,new UTF8Encoding(false))) - { - writer.WriteLine("R129_FAIL_CLOSED_DIAGNOSTIC"); - writer.WriteLine("TEST_PHASE="+phase); - writer.WriteLine("GIT_SHA="+Environment.GetEnvironmentVariable("GITHUB_SHA")); - writer.WriteLine("EXCEPTION="+ex.GetType().FullName); - writer.WriteLine(ex.ToString().Substring(0,Math.Min(12000,ex.ToString().Length))); - writer.Flush(); - } - } - catch{} // Diagnostic must never change Setup's failure/rollback behavior. - } - static int RunMain(string[] args) { if(Has(args,"/verify")) @@ -772,7 +1506,6 @@ static int RunMain(string[] args) UpgradeExisting(); // never fresh-install; rollback journal required return 0; }catch(Exception ex){ - WriteHostedUpgradeFailure(ex); if(!quiet)MessageBox.Show(ex.Message,"In-place update declined",MessageBoxButtons.OK,MessageBoxIcon.Error); return 34; } @@ -832,6 +1565,24 @@ internal static partial class SetupProgram const string InstalledV16="1.6.0"; const string InstalledV170="1.7.0"; const string InstalledV171="1.7.1"; + const string InstalledV172="1.7.2"; + const string InstalledV173="1.7.3"; + const string InstalledV174="1.7.4"; + const string InstalledV175="1.7.5"; + const string InstalledV176="1.7.6"; + const string InstalledV177="1.7.7"; + const string OfficialV177SetupSHA="395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D"; + const string OfficialV177MainSHA="A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253"; + const string OfficialV176SetupSHA="D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0"; + const string OfficialV176MainSHA="0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8"; + const string OfficialV175SetupSHA="8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC"; + const string OfficialV175MainSHA="25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6"; + const string OfficialV174SetupSHA="C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1"; + const string OfficialV174MainSHA="279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C"; + const string OfficialV173SetupSHA="9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B"; + const string OfficialV173MainSHA="6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C"; + const string OfficialV172SetupSHA="225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4"; + const string OfficialV172MainSHA="84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F"; const string OfficialV171SetupSHA="ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89"; const string OfficialV171MainSHA="D50A9D03190719B8385AB2F7F7DBB3129EC7EC1C77ECBD8EF7F29832AEC26420"; const string OfficialV170SetupSHA="148827212B716238DDB270309F393140EBE3CB935CD0E80D36233220A4BE611E"; @@ -952,6 +1703,166 @@ internal static partial class SetupProgram "Uninstall.exe|ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89", }; + // R243: exact immutable official v1.7.2 embedded Setup payload SHA-256, no owner data. + // Root-level .cs is optional for this version; any present copy must match. + static readonly string[] OfficialStableV172Rows=new string[] { + "TaskbarMonitorEnhanced.cs|E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "TaskbarMonitorEnhanced.exe|84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|B71B4F1CD41A12A82DC470534563D2EA24FAAF38233D0158AC13085EF7F75ED0", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|76C65D5A16F384B7600EB4BDEDE7551283235CA32A662A248AF6A6DE03CD5A35", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|A3AD433D281894BB514F225ADEBA007B5F2E8641A1337BF57DE5248D2F96246A", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|FE7F6354C295B71607D14AB5D51843C2C951B2E984399FA5AA00BBF8AA4F8F0E", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.2.md|088EEAF5EE5200CE9686134CBD15F5C6C9436E8027E9DF1613190762911D01FA", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4", + }; + + // R256: pinned immutable public v1.7.3 fresh/upgraded source; never owner data. + // Root-level .cs may be present only if exact SHA matched. + static readonly string[] OfficialStableV173Rows=new string[] { + "TaskbarMonitorEnhanced.cs|5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "TaskbarMonitorEnhanced.exe|6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|F11D20C67056AC054C690C630044F717657DAB6FE26E5997EEBFF25947CF69B9", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|25C2E5BD69BC4210757695501606942ED3ADD0C250848A2CC0B6AB4D8070578F", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|48026FC6A842A6BAEC519E04555A7291CD3DE339CBE59B9AAE635AAA1C1E8D37", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.3.md|CA1A8889BA56AA802BD9A2E2C0A45F885004DB5868702EBAE1076BF52C0F7683", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B", + }; + + // R265: exact 22-file immutable official 1.7.4 ancestor; no optional root source. + static readonly string[] OfficialStableV174Rows=new string[] { + "TaskbarMonitorEnhanced.exe|279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|24460E03A325FD3F085302EAED0DDD7511BAD71F9DE1C722EDD61408CA754CF9", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|23B946D913810FB074F2C904CFF133A44948C57724818E3250B8E21B4D519E37", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|957B655CC028F4D68EB88A430CA4330107D7623004585601233DABD67ABED6B2", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|0D5CC4DAE08C3131DCCC095109EDEA997C757DE65E7A8BA296A2F399E5B67292", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.4.md|FF604D0B69C2116CD1A72C08189FCB18ACA5435894E3FECD822FE972CA996E07", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1", + }; + + // R273: immutable public v1.7.5, 22 exact managed files, not user config. + static readonly string[] OfficialStableV175Rows=new string[] { + "TaskbarMonitorEnhanced.exe|25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|53A37483D56C20A28FB4FA4D8DC207F3B3E06E081F32B7506C8438E321EC0C9E", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|C9872DAA819E9CA7453C158F5A4CB4CAB16FB1078606EE0BAF568555DBE68E57", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|66626A8A762ED34EDC91C7C3D03AD9889CAA5A8FEB64E973EF0EF0E08CDC8AF8", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|74955DE4D73DE9D7AD1C394187E6ACFC95013A92799D8BA9BB7DBDC5F4E7D701", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.5.md|AD3642ABD89BA1C31B2EEA83847FC3ED8861AEBFF9E664B5169A287601AAA16A", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", + }; + + // Official immutable v1.7.6 predecessor: exact 22 managed paths and SHA. + static readonly string[] OfficialStableV176Rows=new string[] { + "TaskbarMonitorEnhanced.exe|0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|086B21052E050B56B753634918D5279C1CAFE4918FE8F2D4FB6F4DC05CF040D5", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|2A3C0152CB8727B8F5AD281AAAA730F12C8D6CBC667866C751C3B866026563B1", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|EFC7104361C855FBE40A673A7CFBD868AAFE663ADC4F465D80A69E8D0E771F7C", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|9CCB3096C6697429527A24E0E2105E5CD7E6B76CEB39B70C8169570D8D796675", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.6.md|D8CF4A1F6596A043E4718E787D0098E4A10ED2D996CEF8766FC268DB72E620E7", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + }; + + // Published immutable official v1.7.7 managed identities, exact 22 files. + static readonly string[] OfficialStableV177Rows=new string[] { + "TaskbarMonitorEnhanced.exe|A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|B95546C73093486D54395FBEE891002A49913F9234713C16A11DCAC23EDFD962", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|7D1B3A1FA0A46CAC47D0063E2ED7929C5299F2DE0FD394BD6C82E53B72590FA5", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|65284119FE8759314BDDD3309837B2377AB6682BE98DF3995363908822A4CA38", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|F136012748AD1D1560EC5D5342D2D1228E5A90EE59B094ECA0E8D3CCAB51A63F", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.7.md|74FDDEBDDE733F1F0E2BE2C84824E4784D4CC244094660199094DA5550091A83", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + }; + sealed class UpgradeTarget { internal readonly string Rel; @@ -979,14 +1890,20 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/COPYRIGHT_AND_ATTRIBUTION.md","Payload.COPYRIGHT_AND_ATTRIBUTION.md"), new UpgradeTarget("Docs/AI_ASSISTED_DEVELOPMENT.md","Payload.AI_ASSISTED_DEVELOPMENT.md"), new UpgradeTarget("Docs/THIRD_PARTY_NOTICES.md","Payload.THIRD_PARTY_NOTICES.md"), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md","Payload.RELEASE_NOTES_v1.7.2.md"), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.8.md","Payload.RELEASE_NOTES_v1.7.8.md"), new UpgradeTarget("Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md","Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md"), new UpgradeTarget("Uninstall.exe","@SELF"), new UpgradeTarget("install_state.json","@INSTALLSTATE"), new UpgradeTarget("TaskbarMonitorEnhanced.cs",null), // redundant v1.6 root duplicate new UpgradeTarget("Docs/RELEASE_NOTES_v1.6.0.md",null), // superseded v1.6 official file new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.0.md",null), // exact historical managed note only - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.1.md",null) // previous version; refuse any user-edited bytes + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.1.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.5.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.6.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.7.md",null) // previous version, guarded byte-for-byte }; static string UpgradeHash(string path) @@ -1012,6 +1929,18 @@ static Dictionary LegacyExpected(string version,string channel) { rows=OfficialStableV170Rows; expectedSetup=OfficialV170SetupSHA; count=23; } else if(version==InstalledV171 && channel=="OFFICIAL_STABLE_V171") { rows=OfficialStableV171Rows; expectedSetup=OfficialV171SetupSHA; count=23; } + else if(version==InstalledV172 && channel=="OFFICIAL_STABLE_V172") + { rows=OfficialStableV172Rows; expectedSetup=OfficialV172SetupSHA; count=23; } + else if(version==InstalledV173 && channel=="OFFICIAL_STABLE_V173") + { rows=OfficialStableV173Rows; expectedSetup=OfficialV173SetupSHA; count=23; } + else if(version==InstalledV177 && channel=="OFFICIAL_STABLE_V177") + { rows=OfficialStableV177Rows; expectedSetup=OfficialV177SetupSHA; count=22; } + else if(version==InstalledV176 && channel=="OFFICIAL_STABLE_V176") + { rows=OfficialStableV176Rows; expectedSetup=OfficialV176SetupSHA; count=22; } + else if(version==InstalledV175 && channel=="OFFICIAL_STABLE_V175") + { rows=OfficialStableV175Rows; expectedSetup=OfficialV175SetupSHA; count=22; } + else if(version==InstalledV174 && channel=="OFFICIAL_STABLE_V174") + { rows=OfficialStableV174Rows; expectedSetup=OfficialV174SetupSHA; count=22; } else if(version==InstalledV170 && channel=="PREVIEW1") { rows=LegacyPreview1Rows; expectedSetup=LegacyPreview1InstallerSha; count=23; } else throw new InvalidDataException("Unsupported exact prior source identity; no downgrade/second root."); @@ -1037,17 +1966,23 @@ static string UPath(string relative) string root=Path.GetFullPath(AppRoot).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; if(!path.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Upgrade refused path outside canonical app root."); + // Path.GetFullPath is lexical only; reject junctions/links before any live I/O. + CheckNoReparse(path); return path; } static void CheckNoReparse(string path) { string root=Path.GetFullPath(AppRoot); - string current=path; + string current=Path.GetFullPath(path); while(!String.IsNullOrEmpty(current)){ - if((File.Exists(current)||Directory.Exists(current)) && - (File.GetAttributes(current)&FileAttributes.ReparsePoint)!=0) - throw new IOException("Upgrade refuses an app component symlink/junction: "+current); + // File.Exists can hide inaccessible or dangling reparse paths. + // Only genuinely absent components may be skipped; all other I/O + // errors must fail closed before a rollback/write/delete. + try{ + if((File.GetAttributes(current)&FileAttributes.ReparsePoint)!=0) + throw new IOException("Upgrade refuses an app component symlink/junction: "+current); + }catch(FileNotFoundException){}catch(DirectoryNotFoundException){} if(String.Equals(current,root,StringComparison.OrdinalIgnoreCase))break; current=Path.GetDirectoryName(current); } @@ -1112,7 +2047,7 @@ static string NewInstallState(string source) throw new InvalidDataException("Old install state Version/PublicVersion mismatch."); string prior=installed.Groups["v"].Value; if(prior==Version)return source; - if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171) + if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174 && prior!=InstalledV175 && prior!=InstalledV176 && prior!=InstalledV177) throw new InvalidDataException("Prior install state version not a pinned officially allowed ancestor."); string next=ReplaceStateVersion(source,"Version",prior); next=ReplaceStateVersion(next,"PublicVersion",prior); @@ -1138,7 +2073,7 @@ static Dictionary PrepareNewBytes(string previousState) if(bytes.Length<1 || fresh.ContainsKey(t.Rel))throw new InvalidDataException("Duplicate/empty payload "+t.Rel); fresh.Add(t.Rel,bytes); } - if(fresh.Count!=UpgradeTargets.Length-4) + if(fresh.Count!=UpgradeTargets.Length-10) throw new InvalidDataException("Upgrade static target map is invalid."); return fresh; } @@ -1147,7 +2082,7 @@ static bool IsOptionalStableLegacyRoot(string rel,string channel) { // Only one SHA-pinned historical Stable file may be absent. Any present // occurrence still requires the exact immutable published source hash. - return (channel=="OFFICIAL_STABLE_V170" || channel=="OFFICIAL_STABLE_V171") && + return (channel=="OFFICIAL_STABLE_V170" || channel=="OFFICIAL_STABLE_V171" || channel=="OFFICIAL_STABLE_V172" || channel=="OFFICIAL_STABLE_V173") && String.Equals(rel,"TaskbarMonitorEnhanced.cs",StringComparison.Ordinal); } @@ -1173,10 +2108,19 @@ static void EnsureAppNotRunning() } static string UpgradeRoot {get{return Path.Combine(AppRoot,UpgradeStageName);}} + static string RetiredUpgradeRoot {get{return Path.Combine(AppRoot,UpgradeStageName+"-retired");}} static string Stage(string part,string rel) { if(part!="new" && part!="old")throw new InvalidDataException("Unexpected stage area."); - return Path.Combine(UpgradeRoot,part,rel.Replace('/',Path.DirectorySeparatorChar)); + if(String.IsNullOrWhiteSpace(rel) || Path.IsPathRooted(rel) || rel.Contains("..") || + rel.Contains(":") || rel.StartsWith("/") || rel.StartsWith("\\")) + throw new InvalidDataException("Upgrade refused unsafe staged resource path."); + string path=Path.GetFullPath(Path.Combine(UpgradeRoot,part,rel.Replace('/',Path.DirectorySeparatorChar))); + string root=Path.GetFullPath(UpgradeRoot).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; + if(!path.StartsWith(root,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Upgrade refused stage escape."); + CheckNoReparse(path); + return path; } static void WritePhase(string phase) @@ -1250,7 +2194,7 @@ static void StageNewManaged(Dictionary fresh, Dictionary ReadStageIndex() (parts[1]!="-" && !Regex.IsMatch(parts[1],"^[0-9A-F]{64}$")) || (parts[2]!="-" && !Regex.IsMatch(parts[2],"^[0-9A-F]{64}$"))) throw new InvalidDataException("Upgrade journal path or digest modified."); - parsed.Add(new StageEntry(parts[0],parts[1],parts[2])); + // Convert the validated on-disk label back to the compiled-in target before filesystem use. + parsed.Add(new StageEntry(UpgradeTargets[i].Rel,parts[1],parts[2])); } return parsed; } + // R299: do not recursively delete a user-writable upgrade journal. A + // junction inserted after the preflight must not turn cleanup into a + // recursive deletion of unrelated user files. static void CleanOwnedStage(List rows) { - HashSet known=new HashSet(StringComparer.OrdinalIgnoreCase); + string root=Path.GetFullPath(UpgradeRoot).TrimEnd(Path.DirectorySeparatorChar); + string prefix=root+Path.DirectorySeparatorChar; + CheckNoReparse(root); + string retired=Path.GetFullPath(RetiredUpgradeRoot); + CheckNoReparse(retired); + if(Directory.Exists(retired) || File.Exists(retired)) + throw new IOException("Prior retired upgrade journal must be recovered first."); + HashSet knownFiles=new HashSet(StringComparer.OrdinalIgnoreCase); foreach(string filename in new string[]{"owner.txt","phase.txt","index.txt","phase.tmp","previous_version.txt"}) - known.Add(Path.GetFullPath(Path.Combine(UpgradeRoot,filename))); + knownFiles.Add(Path.GetFullPath(Path.Combine(root,filename))); foreach(StageEntry r in rows){ - known.Add(Path.GetFullPath(Stage("old",r.Rel))); - known.Add(Path.GetFullPath(Stage("new",r.Rel))); + knownFiles.Add(Path.GetFullPath(Stage("old",r.Rel))); + knownFiles.Add(Path.GetFullPath(Stage("new",r.Rel))); + } + HashSet knownDirs=new HashSet(StringComparer.OrdinalIgnoreCase); + knownDirs.Add(root); + foreach(string filename in knownFiles){ + if(!filename.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)) + throw new IOException("Upgrade cleanup path escaped canonical stage."); + string directory=Path.GetDirectoryName(filename); + while(!String.Equals(directory,root,StringComparison.OrdinalIgnoreCase)){ + if(String.IsNullOrEmpty(directory) || !directory.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)) + throw new IOException("Upgrade cleanup directory escaped canonical stage."); + knownDirs.Add(directory); + directory=Path.GetDirectoryName(directory); + } } - foreach(string path in Directory.GetFiles(UpgradeRoot,"*",SearchOption.AllDirectories)) - if(!known.Contains(Path.GetFullPath(path))) - throw new InvalidOperationException("Unknown file inside update stage; leave stage intact: "+path); - foreach(string path in Directory.GetDirectories(UpgradeRoot,"*",SearchOption.AllDirectories)) - if((File.GetAttributes(path)&FileAttributes.ReparsePoint)!=0) - throw new IOException("Unexpected upgrade stage reparse directory."); - Directory.Delete(UpgradeRoot,true); + // Enumerate one directory at a time: reject unexpected empty dirs as + // well as files and reparse entries BEFORE deleting any known member. + List pending=new List();pending.Add(root); + while(pending.Count>0){ + string directory=pending[pending.Count-1];pending.RemoveAt(pending.Count-1); + CheckNoReparse(directory); + foreach(string entry in Directory.GetFileSystemEntries(directory)){ + string full=Path.GetFullPath(entry); + if(!full.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)) + throw new IOException("Upgrade cleanup observed path escape."); + FileAttributes attributes=File.GetAttributes(full); + if((attributes&FileAttributes.ReparsePoint)!=0) + throw new IOException("Upgrade cleanup refuses reparse member: "+full); + if((attributes&FileAttributes.Directory)!=0){ + if(!knownDirs.Contains(full)) + throw new IOException("Unknown directory inside update stage; leave stage intact: "+full); + pending.Add(full); + }else if(!knownFiles.Contains(full)){ + throw new IOException("Unknown file inside update stage; leave stage intact: "+full); + } + } + } + // Unlink only exact known files; remove directories with recursive=false. + // Keep owner/index/phase markers until descendants have been removed. + string[] metadata=new string[]{"phase.tmp","previous_version.txt","index.txt","phase.txt","owner.txt"}; + HashSet metadataPaths=new HashSet(StringComparer.OrdinalIgnoreCase); + foreach(string name in metadata)metadataPaths.Add(Path.Combine(root,name)); + foreach(string filename in knownFiles){ + if(metadataPaths.Contains(filename))continue; + CheckNoReparse(filename); + if(File.Exists(filename))File.Delete(filename); + } + List directories=knownDirs.Where(d=>!String.Equals(d,root,StringComparison.OrdinalIgnoreCase)).ToList(); + directories.Sort(delegate(string a,string b){ + int c=b.Length.CompareTo(a.Length); + return c!=0?c:StringComparer.OrdinalIgnoreCase.Compare(a,b); + }); + foreach(string directory in directories){ + CheckNoReparse(directory); + if(Directory.Exists(directory))Directory.Delete(directory,false); + } + // Keep index, phase, prior-version and ownership metadata in the + // active journal until a same-parent move retires that whole journal. + // Interruption before the move resumes original recovery; after it + // only a fixed, bounded metadata tombstone remains. + CheckNoReparse(root); + CheckNoReparse(retired); + Directory.Move(root,retired); + FinishRetiredUpgradeStageIfAny(); + } + + // R300: retryable, nonrecursive cleanup of exact retired metadata only. + static void FinishRetiredUpgradeStageIfAny() + { + string root=Path.GetFullPath(RetiredUpgradeRoot); + CheckNoReparse(root); + if(File.Exists(root)) + throw new IOException("Retired upgrade path is not a directory."); + if(!Directory.Exists(root))return; + string owner=Path.Combine(root,"owner.txt"); + string[] metadata=new string[]{"phase.tmp","previous_version.txt","index.txt","phase.txt","owner.txt"}; + HashSet names=new HashSet(metadata,StringComparer.OrdinalIgnoreCase); + string[] entries=Directory.GetFileSystemEntries(root); + foreach(string entry in entries){ + string full=Path.GetFullPath(entry); + if(!String.Equals(Path.GetDirectoryName(full),root,StringComparison.OrdinalIgnoreCase) || + !names.Contains(Path.GetFileName(full))) + throw new IOException("Unknown retired upgrade member; preserve for review: "+full); + CheckNoReparse(full); + FileAttributes attributes=File.GetAttributes(full); + if((attributes&(FileAttributes.Directory|FileAttributes.ReparsePoint))!=0) + throw new IOException("Retired upgrade member is not an ordinary file: "+full); + } + // Owner is last; if it is absent, only an empty directory is safe. + if(File.Exists(owner)){ + if(File.ReadAllText(owner,Encoding.ASCII)!=UpgradeStageMarker) + throw new IOException("Retired upgrade ownership marker mismatch."); + }else if(entries.Length!=0) + throw new IOException("Retired upgrade journal lost its ownership marker."); + foreach(string name in metadata){ + string file=Path.Combine(root,name); + CheckNoReparse(file); + if(File.Exists(file))File.Delete(file); + } + CheckNoReparse(root); + Directory.Delete(root,false); } static void RestoreInterruptedUpgrade(List rows) @@ -1301,7 +2348,19 @@ static void RestoreInterruptedUpgrade(List rows) string priorVersionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); if(!File.Exists(priorVersionPath))throw new IOException("Previous upgrade version missing; retain stage."); string priorVersion=File.ReadAllText(priorVersionPath,Encoding.ASCII); - if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=InstalledV175 && priorVersion!=InstalledV176 && priorVersion!=InstalledV177 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + // R284: an interrupted File.Copy(old, old/rel.restore, false) can + // leave only a partial updater-owned scratch. A retry must recreate + // only these exact scratch paths AFTER owner/index verification. + // Unknown files and reparse paths remain rejected. + foreach(StageEntry row in rows){ + if(row.Previous=="-")continue; + string scratch=Stage("old",row.Rel+".restore"); + if(File.Exists(scratch)){ + CheckNoReparse(scratch); + File.Delete(scratch); + } + } // This works after a mid-commit process kill: exact old snapshots are persisted // on the same volume, and new-only files are removed only if their SHA is known. for(int i=rows.Count-1;i>=0;i--){ @@ -1341,18 +2400,72 @@ static void RestoreInterruptedUpgrade(List rows) } } + // R301: COMMITTING rollback needs a separate durable success marker. + // Otherwise a crash during backup cleanup makes the next COMMITTING + // replay require already-deleted old snapshots. ROLLED_BACK is written + // only after the old live file hashes and HKCU version have been restored. + static void VerifyRolledBackUpgrade(List rows) + { + string versionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); + CheckNoReparse(versionPath); + if(!File.Exists(versionPath)) + throw new IOException("Rolled-back upgrade version missing; retain stage."); + string oldVersion=File.ReadAllText(versionPath,Encoding.ASCII); + if(oldVersion!=InstalledV16 && oldVersion!=InstalledV170 && + oldVersion!=InstalledV171 && oldVersion!=InstalledV172 && + oldVersion!=InstalledV173 && oldVersion!=InstalledV174 && + oldVersion!=InstalledV175 && oldVersion!=InstalledV176 && + oldVersion!=InstalledV177 && oldVersion!=Version) + throw new IOException("Rolled-back upgrade version is not pinned."); + using(RegistryKey key=Registry.CurrentUser.OpenSubKey(UninstallKey,false)){ + if(key==null || !String.Equals(Convert.ToString(key.GetValue("DisplayVersion")), + oldVersion,StringComparison.Ordinal)) + throw new IOException("Rolled-back uninstall registration mismatch."); + } + foreach(StageEntry row in rows){ + string live=UPath(row.Rel); + if(row.Previous=="-"){ + if(File.Exists(live)) + throw new IOException("Rolled-back new component unexpectedly remains: "+row.Rel); + }else if(!File.Exists(live) || UpgradeHash(live)!=row.Previous){ + throw new IOException("Rolled-back previous SHA mismatch: "+row.Rel); + } + } + } + static void RecoverUpgradeStageIfAny() { + FinishRetiredUpgradeStageIfAny(); if(!Directory.Exists(UpgradeRoot))return; CheckNoReparse(UpgradeRoot); + // Interrupted journals are writable in the user's AppData. Before + // reading them or mutating ANY managed file, reject their descendant + // links, including link-shaped phase/index files and stage subtrees. + foreach(string name in new string[]{"owner.txt","phase.txt","phase.tmp", + "index.txt","previous_version.txt","old","new"}) + CheckNoReparse(Path.Combine(UpgradeRoot,name)); string phase=GetPhase(); List rows=ReadStageIndex(); + foreach(StageEntry r in rows){ + UPath(r.Rel); + Stage("old",r.Rel); + Stage("new",r.Rel); + if(r.Previous!="-")Stage("old",r.Rel+".restore"); + } if(phase=="PREPARED"){ // No managed target is modified before COMMITTING is durably written. CleanOwnedStage(rows);return; } if(phase=="COMMITTING"){ RestoreInterruptedUpgrade(rows); + WritePhase("ROLLED_BACK"); + CleanOwnedStage(rows);return; + } + if(phase=="ROLLED_BACK"){ + // A previous rollback already succeeded; its old snapshots may + // have been removed before process death. Verify the exact live + // old version rather than attempting to consume them again. + VerifyRolledBackUpgrade(rows); CleanOwnedStage(rows);return; } if(phase=="COMMITTED"){ @@ -1390,8 +2503,8 @@ static SensorOutcome UpgradeExisting() CheckSingleCanonicalRoot(); string installed=CurrentInstalledVersion(); string newSha=Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"); - if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=Version) - throw new InvalidOperationException("Only SHA-pinned official v1.6, v1.7.0 stable/Preview1 or v1.7.1 may upgrade in place."); + if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=InstalledV175 && installed!=InstalledV176 && installed!=InstalledV177 && installed!=Version) + throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4/v1.7.5/v1.7.6/v1.7.7 may upgrade in place."); string channel="OFFICIAL_V16"; string currentMainSHA=UpgradeHash(AppExe); string installedSetup=UPath("Uninstall.exe"); @@ -1405,7 +2518,13 @@ static SensorOutcome UpgradeExisting() !File.Exists(UPath("TaskbarMonitorEnhanced.cs")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.6.0.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.0.md")) && - !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.1.md"))) + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.1.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.2.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.3.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.4.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.5.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.6.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.7.md"))) return ExistingSensorOutcome(); throw new InvalidOperationException("Unknown current-version maintenance revision; no files changed."); } @@ -1425,6 +2544,45 @@ static SensorOutcome UpgradeExisting() throw new InvalidOperationException("Unknown v1.7.1 source/Setup identity; refuse before any mutation."); channel="OFFICIAL_STABLE_V171"; } + if(installed==InstalledV172) + { + if(currentMainSHA!=OfficialV172MainSHA || oldSetupSHA!=OfficialV172SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.2 source/Setup identity; refuse before any mutation."); + channel="OFFICIAL_STABLE_V172"; + } + if(installed==InstalledV173) + { + if(currentMainSHA!=OfficialV173MainSHA || oldSetupSHA!=OfficialV173SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.3 source/Setup identity; refuse before any mutation."); + channel="OFFICIAL_STABLE_V173"; + } + if(installed==InstalledV174) + { + if(currentMainSHA!=OfficialV174MainSHA || oldSetupSHA!=OfficialV174SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.4 source/Setup identity; refuse before any mutation."); + channel="OFFICIAL_STABLE_V174"; + } + + if(installed==InstalledV177) + { + if(currentMainSHA!=OfficialV177MainSHA || oldSetupSHA!=OfficialV177SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.7 Main/Setup identity; NO mutation."); + channel="OFFICIAL_STABLE_V177"; + } + + if(installed==InstalledV176) + { + if(currentMainSHA!=OfficialV176MainSHA || oldSetupSHA!=OfficialV176SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.6 Main/Setup identity; NO mutation."); + channel="OFFICIAL_STABLE_V176"; + } + + if(installed==InstalledV175) + { + if(currentMainSHA!=OfficialV175MainSHA || oldSetupSHA!=OfficialV175SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.5 Main/Setup identity; NO mutation."); + channel="OFFICIAL_STABLE_V175"; + } // No force-kill; the owner must close their currently running app normally. EnsureAppNotRunning(); diff --git a/src/TaskbarGlass.cs b/src/TaskbarGlass.cs index c920ce3d..54bf9bb1 100644 --- a/src/TaskbarGlass.cs +++ b/src/TaskbarGlass.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.IO; using System.Web.Script.Serialization; // R41: per-pixel taskbar background translucency with independent, contrast-safe glyphs. @@ -21,12 +21,12 @@ internal sealed class GlassToggleSwitch : CheckBox { internal GlassToggleSwitch() { - AutoSize=false;Size=new Size(112,34);TabStop=true;Text=""; + AutoSize=false;Size=new Size(68,34);TabStop=true;Text=""; AccessibleRole=AccessibleRole.CheckButton; SetStyle(ControlStyles.UserPaint|ControlStyles.AllPaintingInWmPaint| - ControlStyles.OptimizedDoubleBuffer|ControlStyles.ResizeRedraw| - ControlStyles.SupportsTransparentBackColor,true); - BackColor=Color.Transparent;Cursor=Cursors.Hand; + ControlStyles.OptimizedDoubleBuffer|ControlStyles.ResizeRedraw,true); + // Never composite other WinForms/native text behind this owner-painted control. + BackColor=Color.FromArgb(20,28,38);Cursor=Cursors.Hand; } protected override void OnCheckedChanged(EventArgs e) {base.OnCheckedChanged(e);Invalidate();} @@ -36,11 +36,16 @@ protected override void OnKeyDown(KeyEventArgs e) {Checked=e.KeyCode==Keys.Right;e.Handled=true;e.SuppressKeyPress=true;return;} base.OnKeyDown(e); } + protected override void OnPaintBackground(PaintEventArgs e) + { + e.Graphics.Clear(BackColor); + } protected override void OnPaint(PaintEventArgs e) { + e.Graphics.Clear(BackColor); e.Graphics.SmoothingMode=System.Drawing.Drawing2D.SmoothingMode.AntiAlias; float h=Math.Max(12,Math.Min(ClientSize.Height-6,26f*DeviceDpi/96f)); - float w=h*1.9f,x=3,y=(ClientSize.Height-h)/2f; + float w=Math.Min(ClientSize.Width-8,h*1.9f),x=4,y=(ClientSize.Height-h)/2f; Color track=SystemInformation.HighContrast? (Checked?SystemColors.Highlight:SystemColors.ControlDark): (Checked?Color.FromArgb(41,128,227):Color.FromArgb(96,106,121)); @@ -54,9 +59,8 @@ protected override void OnPaint(PaintEventArgs e) float inset=Math.Max(2,h/9),d=h-2*inset; using(var thumb=new SolidBrush(SystemInformation.HighContrast?SystemColors.HighlightText:Color.White)) e.Graphics.FillEllipse(thumb,right?x+w-inset-d:x+inset,y+inset,d,d); - TextRenderer.DrawText(e.Graphics,Checked?"On":"Off",Font, - new Rectangle((int)(x+w+8),0,Math.Max(1,ClientSize.Width-(int)(x+w+8)),ClientSize.Height), - Enabled?ForeColor:SystemColors.GrayText,TextFormatFlags.Left|TextFormatFlags.VerticalCenter); + // The ON/OFF word lives in an independent opaque Settings label, not + // on top of the switch's thumb or in an insufficient width at high DPI. if(Focused&&ShowFocusCues)ControlPaint.DrawFocusRectangle(e.Graphics,ClientRectangle); } } @@ -178,30 +182,48 @@ internal static void WritePixels(Bitmap b,byte[] src) finally{b.UnlockBits(d);} } private static byte Byte(double x){return (byte)Math.Max(0,Math.Min(255,(int)Math.Round(x)));} - internal static Bitmap Compose(Bitmap full,Bitmap glyph,Bitmap halo,int opacityPercent,Color taskbar,Color themeBackground,bool glassMode) + // Semantic thermal warning color: preserve an unmistakable red tint even on + // light taskbars, while retaining a contrasting halo around its glyphs. + private static Color ThermalWarningInk(Color taskbarBackground) + { + Color[] candidates={ + Color.FromArgb(255,82,82),Color.FromArgb(255,172,172), + Color.FromArgb(168,0,26),Color.FromArgb(37,0,8)}; + foreach(Color c in candidates) + if(TaskbarGlassColor.Contrast(c,taskbarBackground)>=4.5)return c; + return candidates.OrderByDescending(c=>TaskbarGlassColor.Contrast(c,taskbarBackground)).First(); + } internal static Bitmap Compose(Bitmap full,Bitmap glyph,Bitmap halo,int opacityPercent,Color taskbar,Color themeBackground,bool glassMode,Bitmap thermalWarningMask=null) { if(full==null)throw new ArgumentNullException("full"); if(glyph!=null&&(glyph.Width!=full.Width||glyph.Height!=full.Height))throw new ArgumentException("glyph geometry"); if(halo!=null&&(halo.Width!=full.Width||halo.Height!=full.Height))throw new ArgumentException("halo geometry"); + if(thermalWarningMask!=null&&(thermalWarningMask.Width!=full.Width||thermalWarningMask.Height!=full.Height)) + throw new ArgumentException("thermal warning mask geometry"); int pct=glassMode?0:Math.Max(1,Math.Min(100,opacityPercent)); byte[] basePixels=ReadPixels(full,PixelFormat.Format32bppArgb); byte[] inkPixels=glyph==null?null:ReadPixels(glyph,PixelFormat.Format32bppPArgb); byte[] haloPixels=halo==null?null:ReadPixels(halo,PixelFormat.Format32bppPArgb); + byte[] warningPixels=glassMode&&thermalWarningMask!=null? + ReadPixels(thermalWarningMask,PixelFormat.Format32bppPArgb):null; byte[] pixels=new byte[basePixels.Length]; bool adaptInk=glassMode; // R145: NEVER infer material or recolor ink from an opacity threshold. - Color chosen=TaskbarGlassColor.ReadableText(TaskbarGlassColor.EffectiveBackground(taskbar,themeBackground,pct/100d)); + Color effectiveBackground=TaskbarGlassColor.EffectiveBackground(taskbar,themeBackground,pct/100d); + Color chosen=TaskbarGlassColor.ReadableText(effectiveBackground); + Color warningInk=warningPixels==null?Color.Empty:ThermalWarningInk(effectiveBackground); Color outline=chosen.ToArgb()==Color.White.ToArgb()?Color.Black:Color.White; double backgroundScale=pct/100d; for(int i=0;i=128; + Color textInk=thermalHot?warningInk:chosen; double ring=adaptInk&&haloPixels!=null?Math.Max(0,haloPixels[i+3]/255d-ink)*0.78:0; ring=Math.Min(1-ink,ring); double bg=(1-ink-ring)*backgroundScale*(basePixels[i+3]/255d); pixels[i+3]=Byte((ink+ring+bg)*255); for(int ch=0;ch<3;ch++) { - int textChannel=ch==0?chosen.B:ch==1?chosen.G:chosen.R; + int textChannel=ch==0?textInk.B:ch==1?textInk.G:textInk.R; int outlineChannel=ch==0?outline.B:ch==1?outline.G:outline.R; double glyphContribution=adaptInk?textChannel*ink:(inkPixels==null?0:inkPixels[i+ch]); pixels[i+ch]=Byte(glyphContribution+outlineChannel*ring+basePixels[i+ch]*bg); @@ -363,7 +385,7 @@ private void RefreshGlassAfterMetricCommit() // the opaque last-resort fallback; never misreport successful refresh. } - private Graphics glassInkCanvas,glassHaloCanvas; + private Graphics glassInkCanvas,glassHaloCanvas,glassThermalWarningCanvas; private bool glassLegacyFallback; private bool glassLegacyFallbackReady; private DateTime taskbarGlassSampleAt=DateTime.MinValue; @@ -408,6 +430,15 @@ private Color GlassTaskbarSample() } // R44 chart proof seeds ONLY a purpose-built proof-mode renderer. // Real taskbar metrics/history are never synthesized or persisted. + // Isolated proof only: no telemetry process or user configuration is changed. + internal void SetCpuTemperatureForThermalProof(float temperatureC) + { + if(!proofMode||!MetricStatistics.Finite(temperatureC)) + throw new InvalidOperationException("Thermal pixel stimulus is proof-only."); + snapshot.CpuDevices.Clear(); + snapshot.CpuTempAvailable=true; + snapshot.CpuTempCurrent=temperatureC; + } internal void SeedChartHistoryForProof() { if(!proofMode)throw new InvalidOperationException("Synthetic chart fixture limited to proof mode."); @@ -430,7 +461,7 @@ private Bitmap RenderGlassBitmap(ThemeDefinition theme,int width,int height,int { percent=glassMode?0:Math.Max(1,Math.Min(100,percent)); width=Math.Max(1,width);height=Math.Max(1,height); // Preserve the exact native taskbar footprint, including compact 279px hosts. - Bitmap ink=null,halo=null,full=null; + Bitmap ink=null,halo=null,full=null,thermalMask=null; try { if(percent<100) @@ -447,10 +478,13 @@ private Bitmap RenderGlassBitmap(ThemeDefinition theme,int width,int height,int glassHaloCanvas.TextRenderingHint=System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; glassHaloCanvas.SmoothingMode=System.Drawing.Drawing2D.SmoothingMode.AntiAlias; glassHaloCanvas.Clear(Color.Transparent); + thermalMask=new Bitmap(width,height,PixelFormat.Format32bppPArgb); + glassThermalWarningCanvas=Graphics.FromImage(thermalMask); + glassThermalWarningCanvas.Clear(Color.Transparent); } } full=RenderThemeDefinition(theme,width,height); - Bitmap output=TaskbarGlassPixels.Compose(full,ink,halo,percent,taskbar,theme.Background,glassMode); + Bitmap output=TaskbarGlassPixels.Compose(full,ink,halo,percent,taskbar,theme.Background,glassMode,thermalMask); if(diagnosticBeacon&&!proofMode) using(Graphics beacon=Graphics.FromImage(output))PaintDiagnosticBeacon(beacon); return output; @@ -459,9 +493,11 @@ private Bitmap RenderGlassBitmap(ThemeDefinition theme,int width,int height,int { if(glassInkCanvas!=null){glassInkCanvas.Dispose();glassInkCanvas=null;} if(glassHaloCanvas!=null){glassHaloCanvas.Dispose();glassHaloCanvas=null;} + if(glassThermalWarningCanvas!=null){glassThermalWarningCanvas.Dispose();glassThermalWarningCanvas=null;} if(full!=null)full.Dispose(); if(ink!=null)ink.Dispose(); if(halo!=null)halo.Dispose(); + if(thermalMask!=null)thermalMask.Dispose(); } } private bool PaintPerPixelLayer() @@ -587,6 +623,99 @@ internal static int Run(string dir) } } } + // Exercises the real theme/metric/native bitmap painter with synthetic CPU heat, + // but never creates a native host window or sends a user notification. + internal static class TaskbarGlassThermalProof + { + private static void Require(bool ok,string label) + {if(!ok)throw new InvalidOperationException("R272 thermal glass: "+label);} + private static int RedForeground(Bitmap bitmap) + { + byte[] pixels=TaskbarGlassPixels.ReadPixels(bitmap,PixelFormat.Format32bppPArgb); + int count=0; + for(int i=0;i=215&&r>g+22&&r>b+22&&r>=32)count++; + } + return count; + } + private static int ClearBackground(Bitmap bitmap) + { + byte[] pixels=TaskbarGlassPixels.ReadPixels(bitmap,PixelFormat.Format32bppPArgb); + int count=0; + for(int i=3;i=40,"missing hot red ink "+theme+" "+taskbar+" pixels="+hotRed); + Require(clear>620*48/4,"hot alert made Glass backdrop opaque "+theme); + hotMin=Math.Min(hotMin,hotRed); + clearMin=Math.Min(clearMin,clear); + } + cases++; + } + Require(cases==ThemeCatalog.Names.Length*2,"theme and taskbar background coverage"); + cfg.EnableTemperatureAlerts=false; + using(Bitmap off=form.RenderAppearanceForProof("Retro Terminal",620,48,0,Color.FromArgb(18,20,24),true)) + Require(RedForeground(off)==0,"disabled alert should not tint"); + cfg.EnableTemperatureAlerts=true; + form.SetCpuTemperatureForThermalProof(95); + using(Bitmap normalOpacity=form.RenderAppearanceForProof("Retro Terminal",620,48,29,Color.FromArgb(18,20,24),false)) + { + Require(normalOpacity.Width==620&&normalOpacity.Height==48,"ordinary opacity mode geometry"); + } + } + var result=new{Revision="R272",Status="PASS",Source="REAL_OVERLAY_THEME_AND_METRIC_PAINTER", + HotC=95,ColdC=60,ThresholdC=85,Themes=ThemeCatalog.Names.Length, + ThemeBackgroundPairs=cases,HotRedPixelsMinimum=hotMin,ColdRedPixelsMaximum=nonhotMax, + ClearBackgroundPixelsMinimum=clearMin,OptOutTested=true,NoNativeHostInput=true, + NoUserSettingsOrSensorMutation=true}; + File.WriteAllText(Path.Combine(directory,"THERMAL_GLASS_PROOF.json"), + new JavaScriptSerializer().Serialize(result),new UTF8Encoding(false)); + Console.WriteLine("TBME_THERMAL_GLASS_PROOF=PASS PAIRS="+cases+ + " MIN_HOT_RED="+hotMin+" MIN_CLEAR="+clearMin); + return 0; + } + catch(Exception ex) + { + Console.Error.WriteLine("TBME_THERMAL_GLASS_PROOF=FAIL "+ex); + return 72; + } + } + } // Independent proof: charts must remain foreground at zero opacity. // Only the proof-mode instance receives synthetic history; production never does. internal static class TaskbarChartGlassProof diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index 352c580f..0de9ad2a 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -33,18 +33,18 @@ [assembly: AssemblyDescription("Live system monitor integrated into the Windows taskbar")] [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyInformationalVersion("1.7.2+r155")] +[assembly: AssemblyInformationalVersion("1.7.8+r286")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.2.0")] -[assembly: AssemblyFileVersion("1.7.2.0")] +[assembly: AssemblyVersion("1.7.8.0")] +[assembly: AssemblyFileVersion("1.7.8.0")] namespace TaskbarMonitorEnhanced { internal static class BuildInfo { - public const string Version = "V1_7_2_R155_TASKBAR_FIX"; + public const string Version = "V1_7_8_R286_OPTIONAL_TEMP_SOUND"; public const string Product = "Taskbar Monitor Enhanced"; - public const string PublicVersion = "1.7.2"; + public const string PublicVersion = "1.7.8"; public const string ShortcutName = "Taskbar Monitor Enhanced"; public const string ProductDescription = "Live system monitor integrated into the Windows taskbar"; public const string Author = "Dr. Ali-Akbar Emadeddin"; @@ -414,6 +414,8 @@ public sealed partial class AppConfig public bool AutoCheckUpdates { get; set; } public bool EnableTemperatureAlerts { get; set; } public bool EnableTemperatureNotifications { get; set; } + // Explicitly opt in to the current user's Windows warning sound. + public bool EnableTemperatureWarningSound { get; set; } public int CpuTempWarningC { get; set; } public int GpuTempWarningC { get; set; } public int DiskTempWarningC { get; set; } @@ -457,7 +459,7 @@ public AppConfig() MemoryUnit = "Auto"; StorageUnit = "Auto"; NetworkUnit = "Auto"; DiskRateUnit = "Auto"; ShowRamNumeric = true; ShowDiskNumeric = true; EnableHardwareFlyout = true; HoverShowAllDevices = true; AutoCheckUpdates = true; - EnableTemperatureAlerts = true; EnableTemperatureNotifications = false; CpuTempWarningC = 85; GpuTempWarningC = 85; DiskTempWarningC = 60; + EnableTemperatureAlerts = true; EnableTemperatureNotifications = false; EnableTemperatureWarningSound = false; CpuTempWarningC = 85; GpuTempWarningC = 85; DiskTempWarningC = 60; AdaptiveBatteryMode = true; BatteryUpdateIntervalMs = 3000; SparklineHistoryLength = 120; FollowWindowsTheme=false;WindowsLightTheme="Nordic Light";WindowsDarkTheme="Dark Minimal Pro";TaskbarDisplay="Primary"; InitializeWorkspaceDefaults(); @@ -1004,6 +1006,14 @@ public static bool IsDiskHot(bool available,float value,AppConfig c) public static Color AlertColor { get { return Color.FromArgb(255,82,82); } } } + internal static class TemperatureWarningSoundPolicy + { + // Do not play for a silent notification preference or unavailable UI. + // The caller already enforces snooze/quiet hours and hysteresis. + public static bool ShouldPlay(AppConfig config) + {return config!=null&&config.EnableTemperatureNotifications&&config.EnableTemperatureWarningSound;} + } + internal sealed class TemperatureNotificationState { internal const float HysteresisC=3f; @@ -3477,6 +3487,11 @@ public static bool IsStartSurfacePresent(out string processName,out string class Native.RECT wr; if(!Native.GetWindowRect(h,out wr))return true; Rectangle r=wr.ToRectangle(); + // R190: reject irrelevant window geometry before expensive + // per-HWND .NET process table/name queries. + if(r.Width<300 || r.Height<250)return true; + if(r.Width>=screen.Width-20 && r.Height>=screen.Height-20)return true; + if(r.Bottom < screen.Top + screen.Height/2)return true; uint pid=0; Native.GetWindowThreadProcessId(h,out pid); @@ -3491,9 +3506,6 @@ public static bool IsStartSurfacePresent(out string processName,out string class String.Equals(pn,"SearchApp",StringComparison.OrdinalIgnoreCase); if(!realStartHost)return true; - if(r.Width<300 || r.Height<250)return true; - if(r.Width>=screen.Width-20 && r.Height>=screen.Height-20)return true; - if(r.Bottom < screen.Top + screen.Height/2)return true; StringBuilder sb=new StringBuilder(256); Native.GetClassName(h,sb,sb.Capacity); @@ -4656,6 +4668,18 @@ private void ShowTemperatureNotification(string lane,bool available,float value, RecordAlert(lane+" temperature",body); try{trayIcon.ShowBalloonTip(10000,"Taskbar Monitor Enhanced - temperature warning",body,ToolTipIcon.Warning);Log.Write("WARN","TEMPERATURE_NOTIFICATION lane="+lane+" valueC="+value.ToString("0.0",CultureInfo.InvariantCulture)+" thresholdC="+threshold.ToString("0",CultureInfo.InvariantCulture));} catch(Exception ex){Log.Write("WARN","TEMPERATURE_NOTIFICATION_FAIL lane="+lane+" "+ex.Message);} + if(TemperatureWarningSoundPolicy.ShouldPlay(config)) + { + try + { + // Rate-limited by ShouldNotify above, follows user's Windows + // selected Exclamation sound scheme and output volume. + System.Media.SystemSounds.Exclamation.Play(); + Log.Write("INFO","TEMPERATURE_WARNING_SOUND_REQUESTED lane="+lane); + } + catch(Exception ex) + {Log.Write("WARN","TEMPERATURE_WARNING_SOUND_FAIL lane="+lane+" "+ex.GetType().Name);} + } } private void ReadMetrics() @@ -6288,6 +6312,19 @@ private void PaintMetric(Graphics g,ThemeDefinition t,MetricView m,RectangleF r, { using(Pen hotPen=new Pen(Color.FromArgb(235,AlertPolicy.AlertColor),2f)) g.DrawRectangle(hotPen,inner.X,inner.Y,Math.Max(1,inner.Width-1),Math.Max(1,inner.Height-1)); + // Preserve a semantic warning mask for Glass mode: recolor ONLY + // hot metric glyphs/charts, never normal theme ink or the backdrop. + if(glassThermalWarningCanvas!=null) + { + using(SolidBrush maskBrush=new SolidBrush(Color.White)) + glassThermalWarningCanvas.FillRectangle(maskBrush,inner); + if(glassInkCanvas!=null) + using(Pen rim=new Pen(Color.White,2f)) + glassInkCanvas.DrawRectangle(rim,inner.X,inner.Y,Math.Max(1,inner.Width-1),Math.Max(1,inner.Height-1)); + if(glassHaloCanvas!=null) + using(Pen haloRim=new Pen(Color.White,4f)) + glassHaloCanvas.DrawRectangle(haloRim,inner.X,inner.Y,Math.Max(1,inner.Width-1),Math.Max(1,inner.Height-1)); + } } if(t.Mode=="round"||t.Mode=="glass") { @@ -7070,11 +7107,17 @@ public static int Run() AppConfig c=new AppConfig();c.Normalize(); if(c.ConfigSchemaVersion<6)throw new InvalidOperationException("config schema"); if(c.FollowWindowsTheme||c.WindowsLightTheme!="Nordic Light"||c.WindowsDarkTheme!="Dark Minimal Pro"||c.TaskbarDisplay!="Primary")throw new InvalidOperationException("Windows integration defaults"); - if(!c.EnableTemperatureAlerts||c.EnableTemperatureNotifications||c.CpuTempWarningC!=85||c.GpuTempWarningC!=85||c.DiskTempWarningC!=60) + if(!c.EnableTemperatureAlerts||c.EnableTemperatureNotifications||c.EnableTemperatureWarningSound||c.CpuTempWarningC!=85||c.GpuTempWarningC!=85||c.DiskTempWarningC!=60) throw new InvalidOperationException("temperature alert defaults"); if(!AlertPolicy.IsCpuHot(true,85,c)||AlertPolicy.IsCpuHot(true,84.9f,c))throw new InvalidOperationException("CPU alert boundary"); if(!AlertPolicy.IsGpuHot(true,90,c)||AlertPolicy.IsGpuHot(false,100,c))throw new InvalidOperationException("GPU alert boundary"); if(!AlertPolicy.IsDiskHot(true,60,c)||AlertPolicy.IsDiskHot(true,59.9f,c))throw new InvalidOperationException("disk alert boundary"); + if(TemperatureWarningSoundPolicy.ShouldPlay(c))throw new InvalidOperationException("audio off by default"); + c.EnableTemperatureNotifications=true;c.EnableTemperatureWarningSound=true; + if(!TemperatureWarningSoundPolicy.ShouldPlay(c))throw new InvalidOperationException("audio explicit opt-in"); + c.EnableTemperatureNotifications=false; + if(TemperatureWarningSoundPolicy.ShouldPlay(c))throw new InvalidOperationException("audio muted when toast disabled"); + c.EnableTemperatureWarningSound=false; TemperatureNotificationState notificationState=new TemperatureNotificationState(); DateTime nt=new DateTime(2026,10,2,12,0,0,DateTimeKind.Utc); if(!notificationState.ShouldNotify("CPU",true,85,85,nt))throw new InvalidOperationException("notification first crossing"); @@ -7165,8 +7208,9 @@ private sealed class HardwareChoice private ComboBox theme,windowsLightTheme,windowsDarkTheme,position,taskbarDisplay,cpuMode,gpuMode,diskMode,netMode,multiLayout,memoryUnit,storageUnit,networkUnit,diskRateUnit; private NumericUpDown opacity,interval,width,cpuWarn,gpuWarn,diskWarn,batteryInterval,historyLength; private GlassToggleSwitch glassModeToggle; + private Label glassModeState; private int lastOpacityPercent; - private CheckBox cpu,ram,disk,gpu,vram,net,temp,spark,startup,safe,floatAboveStart,ramNumeric,diskNumeric,flyout,hoverAll,autoCheckUpdates,tempAlerts,tempNotifications,adaptiveBattery,followWindowsTheme; + private CheckBox cpu,ram,disk,gpu,vram,net,temp,spark,startup,safe,floatAboveStart,ramNumeric,diskNumeric,flyout,hoverAll,autoCheckUpdates,tempAlerts,tempNotifications,tempSound,adaptiveBattery,followWindowsTheme; private CheckedListBox cpuList,gpuList,diskList,netList; private Label updateCurrent,updateLatest,updateStatus; private Button checkUpdate,installUpdate,openRelease; @@ -7248,7 +7292,7 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab TabPage display=new TabPage("Display"), metrics=new TabPage("Metrics"), alerts=new TabPage("Alerts"), hardware=new TabPage("Hardware"), units=new TabPage("Units"), behavior=new TabPage("Behavior"), updates=new TabPage("Updates"), diagnostics=new TabPage("Diagnostics"), advanced=new TabPage("Advanced"); tabs.TabPages.Add(display);tabs.TabPages.Add(metrics);tabs.TabPages.Add(alerts);tabs.TabPages.Add(hardware);tabs.TabPages.Add(units);tabs.TabPages.Add(behavior);tabs.TabPages.Add(updates);tabs.TabPages.Add(diagnostics);tabs.TabPages.Add(advanced); foreach(TabPage page in tabs.TabPages){page.AutoScroll=true;page.Padding=new Padding(10);page.BackColor=SettingsWindow;page.ForeColor=SettingsText;} - display.AutoScrollMinSize=new Size(760,440);metrics.AutoScrollMinSize=new Size(760,430);alerts.AutoScrollMinSize=new Size(760,430);hardware.AutoScrollMinSize=new Size(760,760);units.AutoScrollMinSize=new Size(760,540);behavior.AutoScrollMinSize=new Size(760,590);updates.AutoScrollMinSize=new Size(760,540);diagnostics.AutoScrollMinSize=new Size(840,700);advanced.AutoScrollMinSize=new Size(760,540); + display.AutoScrollMinSize=new Size(760,440);metrics.AutoScrollMinSize=new Size(760,430);alerts.AutoScrollMinSize=new Size(760,520);hardware.AutoScrollMinSize=new Size(760,760);units.AutoScrollMinSize=new Size(760,540);behavior.AutoScrollMinSize=new Size(760,590);updates.AutoScrollMinSize=new Size(760,540);diagnostics.AutoScrollMinSize=new Size(840,700);advanced.AutoScrollMinSize=new Size(760,540); theme=Combo(display,"Theme",ThemeCatalog.Names,c.Theme,22);position=Combo(display,"Position",new string[]{"Left","Center","Right"},c.Position,68); taskbarDisplay=Combo(display,"Taskbar monitor",TaskbarTargetPolicy.SettingChoices(c.TaskbarDisplay),c.TaskbarDisplay,114); @@ -7256,9 +7300,16 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab Label glassLabel=new Label{Text="Glass mode",Location=new Point(24,164),Size=new Size(145,26)}; display.Controls.Add(glassLabel); glassModeToggle=new GlassToggleSwitch{Location=new Point(180,158), - Size=new Size(112,34),Checked=c.GlassMode}; + Size=new Size(68,34),Checked=c.GlassMode}; SetA11y(glassModeToggle,"Glass mode switch","Click or press Space to switch Glass on or off. On: zero background; Off: your saved 1-100% opacity."); display.Controls.Add(glassModeToggle); + // A separate, opaque status label eliminates mixed native CheckBox text + // painting and keeps track, thumb and ON/OFF text spatially independent. + glassModeState=new Label{Text=c.GlassMode?"On":"Off",Location=new Point(260,160), + Size=new Size(72,30),AutoSize=false,TextAlign=ContentAlignment.MiddleLeft, + ForeColor=SettingsText,BackColor=SettingsSurface}; + SetA11y(glassModeState,"Glass mode status","Current Glass mode state."); + display.Controls.Add(glassModeState); lastOpacityPercent=Math.Max(1,Math.Min(100,(int)Math.Round(c.OpacityWhenGlassOff*100))); opacity=Number(display,"Background opacity %",c.GlassMode?0:(decimal)(c.Opacity*100),c.GlassMode?0:1,100,216); opacity.Enabled=!c.GlassMode; @@ -7274,10 +7325,25 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab tempAlerts=Check(alerts,"Enable temperature warning highlights",c.EnableTemperatureAlerts,24); tempNotifications=Check(alerts,"Show Windows notification when a threshold is crossed",c.EnableTemperatureNotifications,55); - cpuWarn=Number(alerts,"CPU warning °C",c.CpuTempWarningC,50,110,102); - gpuWarn=Number(alerts,"GPU warning °C",c.GpuTempWarningC,50,110,152); - diskWarn=Number(alerts,"Disk warning °C",c.DiskTempWarningC,40,90,202); - Label alertNote=new Label();alertNote.Location=new Point(24,258);alertNote.Size=new Size(690,120);alertNote.Text="Selected CPU, GPU or disk temperatures can switch the metric card to a high-visibility warning accent. Optional Windows notifications fire only on a new hot transition, use a 3 C recovery hysteresis and a 10-minute per-lane cooldown. Unsupported or stale temperature data never creates a false hot state.";alerts.Controls.Add(alertNote); + tempSound=Check(alerts,"Play Windows warning sound on a new hot threshold",c.EnableTemperatureWarningSound,86); + cpuWarn=Number(alerts,"CPU warning °C",c.CpuTempWarningC,50,110,135); + gpuWarn=Number(alerts,"GPU warning °C",c.GpuTempWarningC,50,110,185); + diskWarn=Number(alerts,"Disk warning °C",c.DiskTempWarningC,40,90,235); + Label alertNote=new Label();alertNote.Location=new Point(24,284);alertNote.Size=new Size(690,120);alertNote.Text="Selected CPU, GPU or disk temperatures can switch the metric card to a high-visibility warning accent. Optional Windows notifications fire only on a new hot transition, use a 3 C recovery hysteresis and a 10-minute per-lane cooldown. Unsupported or stale temperature data never creates a false hot state. Sound is opt-in and follows your Windows sound scheme.";alerts.Controls.Add(alertNote); + Button testWarningSound=new Button{Text="Test warning sound",Location=new Point(24,420), + Size=new Size(200,36),AutoSize=false}; + SetA11y(testWarningSound,"Test temperature warning sound", + "Play one Windows Exclamation sound without heating the CPU or changing alarm thresholds."); + testWarningSound.Click+=delegate + { + try + { + System.Media.SystemSounds.Exclamation.Play(); + Log.Write("INFO","TEMPERATURE_WARNING_SOUND_TEST_REQUESTED"); + } + catch(Exception ex){Log.Write("WARN","TEMPERATURE_WARNING_SOUND_TEST_FAILED "+ex.GetType().Name);} + }; + alerts.Controls.Add(testWarningSound); hardware.AutoScroll=true;hardware.AutoScrollMargin=new Size(0,24);int y=16; AddHardwareSection(hardware,"CPU",c.CpuDisplayMode,ChoicesCpu(),c.SelectedCpuIds,y,out cpuMode,out cpuList);y+=118; @@ -7333,7 +7399,9 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab ModernizeSettingsPage(diagnostics,"Diagnostics","Inspect live health, copy reports and export support evidence."); ModernizeSettingsPage(advanced,"Advanced","Maintenance, logs and safe configuration reset."); - themePreview=new ThemePreviewControl(themePreviewRenderer);themePreview.Location=new Point(24,500);themePreview.Size=new Size(650,132);themePreview.Anchor=AnchorStyles.Top|AnchorStyles.Left|AnchorStyles.Right;display.AutoScrollMinSize=new Size(760,680);display.Controls.Add(themePreview);themePreview.BringToFront(); + // The note extends to y=508 after the page modernization shift. + // The former preview at y=500 overlapped it by 8px; leave a 28px gap. + themePreview=new ThemePreviewControl(themePreviewRenderer);themePreview.Location=new Point(24,536);themePreview.Size=new Size(650,132);themePreview.Anchor=AnchorStyles.Top|AnchorStyles.Left|AnchorStyles.Right;display.AutoScrollMinSize=new Size(760,720);display.Controls.Add(themePreview);themePreview.BringToFront(); theme.SelectedIndexChanged+=delegate{UpdateThemeModeUi();};windowsLightTheme.SelectedIndexChanged+=delegate{UpdateThemeModeUi();};windowsDarkTheme.SelectedIndexChanged+=delegate{UpdateThemeModeUi();};followWindowsTheme.CheckedChanged+=delegate{UpdateThemeModeUi();};UpdateThemeModeUi(); EnsureAccessibilityMetadata(this); @@ -7411,6 +7479,12 @@ private void StyleSettingsControl(Control control) { NumericUpDown n=(NumericUpDown)control;n.BackColor=SettingsSurface2;n.ForeColor=SettingsText;n.BorderStyle=BorderStyle.FixedSingle;foreach(Control child in n.Controls){child.BackColor=SettingsSurface2;child.ForeColor=SettingsText;} } + else if(control is GlassToggleSwitch) + { + // Native Flat CheckBox paint is never allowed over custom track ink. + GlassToggleSwitch x=(GlassToggleSwitch)control; + x.FlatStyle=FlatStyle.Standard;x.ForeColor=SettingsText;x.BackColor=SettingsSurface; + } else if(control is CheckBox) { CheckBox x=(CheckBox)control;x.ForeColor=SettingsText;x.FlatStyle=FlatStyle.Flat;x.FlatAppearance.BorderColor=SettingsAccent; @@ -7522,6 +7596,7 @@ private string ResolvedPreviewThemeName() private void SyncGlassOpacityUi() { if(glassModeToggle==null || opacity==null)return; + if(glassModeState!=null)glassModeState.Text=glassModeToggle.Checked?"On":"Off"; if(glassModeToggle.Checked) { if(opacity.Enabled && opacity.Value>0)lastOpacityPercent=(int)opacity.Value; @@ -7796,7 +7871,7 @@ private void ResetControlsToSafeDefaults() memoryUnit.SelectedItem=d.MemoryUnit;storageUnit.SelectedItem=d.StorageUnit;diskRateUnit.SelectedItem=d.DiskRateUnit;networkUnit.SelectedItem=d.NetworkUnit; ramNumeric.Checked=d.ShowRamNumeric;diskNumeric.Checked=d.ShowDiskNumeric;flyout.Checked=d.EnableHardwareFlyout;hoverAll.Checked=d.HoverShowAllDevices; interval.Value=d.UpdateIntervalMs;startup.Checked=d.StartWithWindows;safe.Checked=d.SafePlacement;floatAboveStart.Checked=d.FloatAboveStartMenu;autoCheckUpdates.Checked=d.AutoCheckUpdates; - tempAlerts.Checked=d.EnableTemperatureAlerts;tempNotifications.Checked=d.EnableTemperatureNotifications;cpuWarn.Value=d.CpuTempWarningC;gpuWarn.Value=d.GpuTempWarningC;diskWarn.Value=d.DiskTempWarningC; + tempAlerts.Checked=d.EnableTemperatureAlerts;tempNotifications.Checked=d.EnableTemperatureNotifications;tempSound.Checked=d.EnableTemperatureWarningSound;cpuWarn.Value=d.CpuTempWarningC;gpuWarn.Value=d.GpuTempWarningC;diskWarn.Value=d.DiskTempWarningC; adaptiveBattery.Checked=d.AdaptiveBatteryMode;batteryInterval.Value=d.BatteryUpdateIntervalMs;historyLength.Value=d.SparklineHistoryLength;followWindowsTheme.Checked=d.FollowWindowsTheme;windowsLightTheme.SelectedItem=d.WindowsLightTheme;windowsDarkTheme.SelectedItem=d.WindowsDarkTheme;taskbarDisplay.SelectedItem=d.TaskbarDisplay; CheckAll(cpuList);CheckAll(gpuList);CheckAll(diskList);CheckAll(netList); if(themePreview!=null)themePreview.ThemeName=d.Theme; @@ -7869,7 +7944,7 @@ private void Apply() c.Opacity=c.GlassMode?0.0:(double)opacity.Value/100.0; c.MinWidthLogicalPx=(int)width.Value;c.UpdateIntervalMs=(int)interval.Value;c.ShowCpu=cpu.Checked;c.ShowRam=ram.Checked;c.ShowDisk=disk.Checked;c.ShowGpu=gpu.Checked;c.ShowVram=vram.Checked;c.ShowNetwork=net.Checked;c.ShowTemperatures=temp.Checked;c.ShowSparklines=spark.Checked;c.StartWithWindows=startup.Checked;c.SafePlacement=safe.Checked;c.FloatAboveStartMenu=floatAboveStart.Checked; c.CpuDisplayMode=(string)cpuMode.SelectedItem;c.GpuDisplayMode=(string)gpuMode.SelectedItem;c.DiskDisplayMode=(string)diskMode.SelectedItem;c.NetworkDisplayMode=(string)netMode.SelectedItem;c.SelectedCpuIds=CheckedIds(cpuList);c.SelectedGpuIds=CheckedIds(gpuList);c.SelectedDiskIds=CheckedIds(diskList);c.SelectedNetworkIds=CheckedIds(netList);c.MultipleDeviceLayout=(string)multiLayout.SelectedItem;c.MemoryUnit=(string)memoryUnit.SelectedItem;c.StorageUnit=(string)storageUnit.SelectedItem;c.DiskRateUnit=(string)diskRateUnit.SelectedItem;c.NetworkUnit=(string)networkUnit.SelectedItem;c.ShowRamNumeric=ramNumeric.Checked;c.ShowDiskNumeric=diskNumeric.Checked;c.EnableHardwareFlyout=flyout.Checked;c.HoverShowAllDevices=hoverAll.Checked;c.AutoCheckUpdates=autoCheckUpdates.Checked; - c.EnableTemperatureAlerts=tempAlerts.Checked;c.EnableTemperatureNotifications=tempNotifications.Checked;c.CpuTempWarningC=(int)cpuWarn.Value;c.GpuTempWarningC=(int)gpuWarn.Value;c.DiskTempWarningC=(int)diskWarn.Value;c.AdaptiveBatteryMode=adaptiveBattery.Checked;c.BatteryUpdateIntervalMs=(int)batteryInterval.Value;c.SparklineHistoryLength=(int)historyLength.Value;c.FollowWindowsTheme=followWindowsTheme.Checked;c.WindowsLightTheme=Convert.ToString(windowsLightTheme.SelectedItem,CultureInfo.InvariantCulture);c.WindowsDarkTheme=Convert.ToString(windowsDarkTheme.SelectedItem,CultureInfo.InvariantCulture);c.TaskbarDisplay=TaskbarTargetPolicy.NormalizeConfigId(Convert.ToString(taskbarDisplay.SelectedItem,CultureInfo.InvariantCulture)); + c.EnableTemperatureAlerts=tempAlerts.Checked;c.EnableTemperatureNotifications=tempNotifications.Checked;c.EnableTemperatureWarningSound=tempSound.Checked;c.CpuTempWarningC=(int)cpuWarn.Value;c.GpuTempWarningC=(int)gpuWarn.Value;c.DiskTempWarningC=(int)diskWarn.Value;c.AdaptiveBatteryMode=adaptiveBattery.Checked;c.BatteryUpdateIntervalMs=(int)batteryInterval.Value;c.SparklineHistoryLength=(int)historyLength.Value;c.FollowWindowsTheme=followWindowsTheme.Checked;c.WindowsLightTheme=Convert.ToString(windowsLightTheme.SelectedItem,CultureInfo.InvariantCulture);c.WindowsDarkTheme=Convert.ToString(windowsDarkTheme.SelectedItem,CultureInfo.InvariantCulture);c.TaskbarDisplay=TaskbarTargetPolicy.NormalizeConfigId(Convert.ToString(taskbarDisplay.SelectedItem,CultureInfo.InvariantCulture)); } } @@ -10218,6 +10293,7 @@ private static int Main(string[] args) { Native.EnableDllSearchHardening(); if(args!=null && args.Length>1 && args[0]=="--glassproof")return TaskbarGlassProof.Run(args[1]); + if(args!=null && args.Length>1 && args[0]=="--glassthermalproof")return TaskbarGlassThermalProof.Run(args[1]); if(args!=null && args.Length>1 && args[0]=="--chartglassproof")return TaskbarChartGlassProof.Run(args[1]); if(args!=null && args.Length>1 && args[0]=="--glassliveproof")return TaskbarGlassLiveProof.Run(args[1]); if(args!=null && args.Length>1 && args[0]=="--topglassliveproof")return TaskbarTopGlassVisualProof.Run(args[1]); diff --git a/src/WorkspaceSelfTest.cs b/src/WorkspaceSelfTest.cs index 45714d11..634d9188 100644 --- a/src/WorkspaceSelfTest.cs +++ b/src/WorkspaceSelfTest.cs @@ -126,7 +126,7 @@ internal static int Run() { try { - Check(BuildInfo.PublicVersion=="1.7.2","version"); + Check(BuildInfo.PublicVersion=="1.7.8","version"); for(int schema=0;schema<7;schema++){ var legacy=new AppConfig{ConfigSchemaVersion=schema,EnableTemperatureNotifications=true,CpuTempWarningC=0,FontSize=Double.NaN,Opacity=Double.NaN};legacy.Normalize(); Check(legacy.ConfigSchemaVersion==8,"all legacy schemas migrate "+schema); diff --git a/src/sensors/TaskbarMonitorSensorSupervisor.cs b/src/sensors/TaskbarMonitorSensorSupervisor.cs index 8ed1d932..e3a6b81a 100644 --- a/src/sensors/TaskbarMonitorSensorSupervisor.cs +++ b/src/sensors/TaskbarMonitorSensorSupervisor.cs @@ -449,18 +449,99 @@ static void WriteState(string reason) catch{} } + // R338: Supervisor may run at high integrity. CLI-supplied --broker + // must never choose an executable; only the adjacent protected Broker is + // allowed. Output must be the exact interactive user's canonical root. + // Path checks are fail-closed, not a claim of atomic rename protection. + static bool NoReparseComponents(string fullPath) + { + string cursor=Path.GetFullPath(fullPath); + while(!String.IsNullOrEmpty(cursor)) + { + try + { + if((File.GetAttributes(cursor)&FileAttributes.ReparsePoint)!=0) + return false; + } + catch(FileNotFoundException){} + catch(DirectoryNotFoundException){} + string parent=Path.GetDirectoryName(cursor); + if(String.IsNullOrEmpty(parent)||String.Equals(parent,cursor,StringComparison.OrdinalIgnoreCase)) + break; + cursor=parent; + } + return true; + } + + // Separate deterministic function for exact compiled-PE native negative + // tests. Production supplies OS known folders; tests pass isolated paths. + static bool TrustedWorkerPaths(string broker,string output,string executableDirectory, + string programFiles,string profile,string localAppData) + { + try + { + if(String.IsNullOrWhiteSpace(broker)||String.IsNullOrWhiteSpace(output)|| + String.IsNullOrWhiteSpace(executableDirectory)||String.IsNullOrWhiteSpace(programFiles)|| + String.IsNullOrWhiteSpace(profile)||String.IsNullOrWhiteSpace(localAppData)|| + broker.Length>2048||output.Length>2048|| + !Path.IsPathRooted(broker)||!Path.IsPathRooted(output)) + return false; + string pf=Path.GetFullPath(programFiles).TrimEnd(Path.DirectorySeparatorChar); + string user=Path.GetFullPath(profile).TrimEnd(Path.DirectorySeparatorChar); + string local=Path.GetFullPath(localAppData).TrimEnd(Path.DirectorySeparatorChar); + string dir=Path.GetFullPath(executableDirectory).TrimEnd(Path.DirectorySeparatorChar); + if(pf.StartsWith(@"\\",StringComparison.Ordinal)|| + user.StartsWith(@"\\",StringComparison.Ordinal)|| + local.StartsWith(@"\\",StringComparison.Ordinal)|| + dir.StartsWith(@"\\",StringComparison.Ordinal)) + return false; + string expectedLocal=Path.Combine(user,"AppData","Local"); + if(!String.Equals(local,expectedLocal,StringComparison.OrdinalIgnoreCase)) + return false; + string protectedDir=Path.Combine(pf,"TaskbarMonitorEnhanced","SensorBroker"); + string expectedBroker=Path.Combine(protectedDir,"TaskbarMonitorSensorBroker.exe"); + string expectedOutput=Path.Combine(local,"TaskbarMonitorEnhanced","cpu_temp_broker.json"); + if(!String.Equals(dir,protectedDir,StringComparison.OrdinalIgnoreCase)|| + !String.Equals(Path.GetFullPath(broker),expectedBroker,StringComparison.OrdinalIgnoreCase)|| + !String.Equals(Path.GetFullPath(output),expectedOutput,StringComparison.OrdinalIgnoreCase)|| + !File.Exists(expectedBroker)|| + !Directory.Exists(Path.GetDirectoryName(expectedOutput))) + return false; + return NoReparseComponents(expectedBroker)&&NoReparseComponents(expectedOutput); + } + catch{return false;} + } + static bool ParseArgs(string[] args) { - string legacyOutput=""; - for(int i=0;i