Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ jobs:
exit 1
}

- name: Run Store search suggestion tests
shell: pwsh
run: |
$result = Invoke-Pester -Path "Tests/Unit/Test-StoreSearchSuggestions.ps1" -Output Detailed -PassThru
if ($result.FailedCount -gt 0) {
Write-Error "$($result.FailedCount) test(s) failed in Test-StoreSearchSuggestions.ps1"
exit 1
}

- name: Run feature update pin tests
shell: pwsh
run: |
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Document all notable Winnow changes in this file. Releases before 4.0.0 were pub

Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Undoing "Disable Store search suggestions" failed with access denied. The undo removes the Everyone deny rule the feature puts on each user's `store.db` and then deletes the file, but the rule check was a `try` statement inside plain parentheses, which Windows PowerShell parses as a call to a command named `try`. It failed on every undo, the deny rule stayed, and deleting the file was then refused, so the feature could not be reverted through Winnow. Covered by `Test-StoreSearchSuggestions.ps1`, which runs the disable and undo against a real file.
- Two new build checks scan every script Winnow loads: one fails if a PowerShell keyword is being invoked as a command, the other if any call resolves to neither a Winnow function, a cmdlet, nor one of the external programs Winnow deliberately runs. The second is what found the bug above; the codebase has no other call to a missing command.

## [4.3.0] - 2026-09-23

### Added
Expand Down
2 changes: 1 addition & 1 deletion Scripts/Features/StoreSearchSuggestions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function EnableStoreSearchSuggestions {
$acl.Access | Where-Object {
$_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
(($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0) -and
(try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid } catch { $false })
$(try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid } catch { $false })
}
)

Expand Down
42 changes: 42 additions & 0 deletions Tests/Unit/Test-SafetyGuards.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,22 @@ Describe 'Winnow script loading' {
$script:loadedScripts = @([regex]::Matches($entry, $pattern) | ForEach-Object {
($_.Groups[1].Value -replace '/', '\').ToLowerInvariant()
})

# Every function the loaded scripts define, and every command they call.
$script:definedFunctions = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
$script:commandCalls = New-Object System.Collections.Generic.List[object]
foreach ($relative in @('winnow.ps1') + $script:loadedScripts) {
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:loadRoot $relative), [ref]$null, [ref]$null)
foreach ($definition in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
[void]$script:definedFunctions.Add($definition.Name)
}
foreach ($call in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) {
$name = $call.GetCommandName()
if ($name) {
$script:commandCalls.Add([PSCustomObject]@{ Name = $name; Where = ('{0}:{1}' -f $relative, $call.Extent.StartLineNumber) })
}
}
}
}

It 'dot-sources every script under Scripts apart from the ones kept out by design' {
Expand All @@ -182,4 +198,30 @@ Describe 'Winnow script loading' {
$script:loadedScripts.Count | Should -BeGreaterThan 50 -Because 'the dot-source pattern must actually match the entry script'
$unloaded | Should -BeNullOrEmpty -Because "not dot-sourced by Winnow.ps1: $($unloaded -join ', ')"
}

It 'never invokes a PowerShell keyword as a command' {
# A statement such as try placed inside plain parentheses is parsed as a
# command name and fails at run time with "The term 'try' is not
# recognized". That hid in the Store search suggestion undo, where it
# only ran once an Everyone deny rule was present.
$keywords = @('try', 'catch', 'finally', 'if', 'elseif', 'else', 'foreach', 'for', 'while', 'do', 'until', 'switch', 'function', 'filter', 'return', 'break', 'continue', 'throw', 'trap', 'exit', 'param', 'begin', 'process', 'end', 'data')
$misparsed = @($script:commandCalls | Where-Object { $_.Name -in $keywords } | ForEach-Object { "$($_.Name) at $($_.Where)" })

$misparsed | Should -BeNullOrEmpty -Because "keywords parsed as commands: $($misparsed -join '; ')"
}

It 'calls only commands that exist' {
# Functions come from the loaded scripts; anything else must be a cmdlet or
# alias, or one of the external programs Winnow deliberately runs. A new
# external program has to be added here, which keeps shell-outs reviewed.
$externalPrograms = @('winget', 'DISM', 'reg', 'netsh', 'takeown', 'icacls', 'powercfg', 'bcdedit')
$candidates = @($script:commandCalls.Name | Sort-Object -Unique | Where-Object {
-not $script:definedFunctions.Contains($_) -and $_ -notin $externalPrograms
})
$missing = @($candidates | Where-Object { -not (Get-Command -Name $_ -CommandType Cmdlet, Alias, Function -ErrorAction SilentlyContinue) })
$unresolved = @($script:commandCalls | Where-Object { $_.Name -in $missing } | ForEach-Object { "$($_.Name) at $($_.Where)" })

$script:commandCalls.Count | Should -BeGreaterThan 1000 -Because 'the scan must actually see the scripts'
$unresolved | Should -BeNullOrEmpty -Because "calls that resolve to nothing: $($unresolved -join '; ')"
}
}
60 changes: 60 additions & 0 deletions Tests/Unit/Test-StoreSearchSuggestions.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#Requires -Modules Pester
<#
.SYNOPSIS
Unit tests for the Microsoft Store search suggestion toggle.
.DESCRIPTION
Disabling suggestions puts an Everyone deny FullControl rule on the user's
store.db; undoing it removes that rule and deletes the file. These run against
a real temporary file so the ACL handling is exercised for real. takeown and
icacls are mocked: they act on ownership, which is not what is under test.

The undo test checks for the ACL warning as well as for the file being gone.
In a temp folder the file can be deleted through the folder's own delete
permission even when its deny rule was never removed, so the deletion alone
would not show that the rule removal failed.
#>

BeforeAll {
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') | Select-Object -ExpandProperty Path
. (Join-Path $repoRoot 'Scripts\Features\StoreSearchSuggestions.ps1')
}

Describe 'Store search suggestions' {

BeforeEach {
$script:Params = @{}
$script:storeDb = Join-Path $TestDrive 'store.db'
Set-Content -LiteralPath $script:storeDb -Value 'placeholder'
Mock takeown { }
Mock icacls { }
Mock Write-Host { }
Mock Write-Warning { }
}

AfterEach {
# Leave nothing behind that the test runner cannot delete.
if (Test-Path -LiteralPath $script:storeDb) {
$acl = Get-Acl -Path $script:storeDb
foreach ($rule in @($acl.Access | Where-Object { $_.AccessControlType -eq 'Deny' })) {
[void]$acl.RemoveAccessRule($rule)
}
Set-Acl -Path $script:storeDb -AclObject $acl
Remove-Item -LiteralPath $script:storeDb -Force
}
}

It 'puts an Everyone deny rule on the database when disabling' {
DisableStoreSearchSuggestions -StoreAppsDatabase $script:storeDb

Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $script:storeDb | Should -BeTrue
}

It 'removes the deny rule and deletes the database when undoing' {
DisableStoreSearchSuggestions -StoreAppsDatabase $script:storeDb

EnableStoreSearchSuggestions -StoreAppsDatabase $script:storeDb

Should -Invoke Write-Warning -Times 0 -Exactly -ParameterFilter { $Message -like '*normalize ACL*' }
Test-Path -LiteralPath $script:storeDb | Should -BeFalse
}
}
2 changes: 1 addition & 1 deletion Winnow-Standalone.ps1

Large diffs are not rendered by default.