diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c2239935..b020b5d244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **bucket:** Make official buckets higher priority ([#5398](https://github.com/ScoopInstaller/Scoop/issues/5398)) - **core:** Add `-Quiet` switch for `Invoke-ExternalCommand` ([#5346](https://github.com/ScoopInstaller/Scoop/issues/5346)) - **core:** Allow global install of PowerShell modules ([#5611](https://github.com/ScoopInstaller/Scoop/issues/5611)) +- **path:** Isolate Scoop apps' PATH ([#5840](https://github.com/ScoopInstaller/Scoop/issues/5840)) ### Bug Fixes diff --git a/bin/uninstall.ps1 b/bin/uninstall.ps1 index 9e89481959..98b5c8d513 100644 --- a/bin/uninstall.ps1 +++ b/bin/uninstall.ps1 @@ -100,5 +100,8 @@ if ($purge) { } Remove-Path -Path (shimdir $global) -Global:$global +if (get_config USE_ISOLATED_PATH) { + Remove-Path -Path ('%' + $scoopPathEnvVar + '%') -Global:$global +} success 'Scoop has been uninstalled.' diff --git a/lib/core.ps1 b/lib/core.ps1 index 37c96ebe43..cf60f27e4c 100644 --- a/lib/core.ps1 +++ b/lib/core.ps1 @@ -132,6 +132,9 @@ function set_config { $value = [System.Convert]::ToBoolean($value) } + # Initialize config's change + Complete-ConfigChange -Name $name -Value $value + if ($null -eq $scoopConfig.$name) { $scoopConfig | Add-Member -MemberType NoteProperty -Name $name -Value $value } else { @@ -147,6 +150,74 @@ function set_config { return $scoopConfig } +function Complete-ConfigChange { + [CmdletBinding()] + param ( + [Parameter(Mandatory, Position = 0)] + [string] + $Name, + [Parameter(Mandatory, Position = 1)] + [AllowEmptyString()] + [string] + $Value + ) + + if ($Name -eq 'use_isolated_path') { + $oldValue = get_config USE_ISOLATED_PATH + if ($Value -eq $oldValue) { + return + } else { + $currPathEnvVar = $scoopPathEnvVar + } + . "$PSScriptRoot\..\lib\system.ps1" + + if ($Value -eq $false -or $Value -eq '') { + info 'Turn off Scoop isolated path... This may take a while, please wait.' + $movedPath = Get-EnvVar -Name $currPathEnvVar + if ($movedPath) { + Add-Path -Path $movedPath -Quiet + Remove-Path -Path ('%' + $currPathEnvVar + '%') -Quiet + Set-EnvVar -Name $currPathEnvVar -Quiet + } + if (is_admin) { + $movedPath = Get-EnvVar -Name $currPathEnvVar -Global + if ($movedPath) { + Add-Path -Path $movedPath -Global -Quiet + Remove-Path -Path ('%' + $currPathEnvVar + '%') -Global -Quiet + Set-EnvVar -Name $currPathEnvVar -Global -Quiet + } + } + } else { + $newPathEnvVar = if ($Value -eq $true) { + 'SCOOP_PATH' + } else { + $Value.ToUpperInvariant() + } + info "Turn on Scoop isolated path ('$newPathEnvVar')... This may take a while, please wait." + $movedPath = Remove-Path -Path "$scoopdir\apps\*" -TargetEnvVar $currPathEnvVar -Quiet -PassThru + if ($movedPath) { + Add-Path -Path $movedPath -TargetEnvVar $newPathEnvVar -Quiet + Add-Path -Path ('%' + $newPathEnvVar + '%') -Quiet + if ($currPathEnvVar -ne 'PATH') { + Remove-Path -Path ('%' + $currPathEnvVar + '%') -Quiet + Set-EnvVar -Name $currPathEnvVar -Quiet + } + } + if (is_admin) { + $movedPath = Remove-Path -Path "$globaldir\apps\*" -TargetEnvVar $currPathEnvVar -Global -Quiet -PassThru + if ($movedPath) { + Add-Path -Path $movedPath -TargetEnvVar $newPathEnvVar -Global -Quiet + Add-Path -Path ('%' + $newPathEnvVar + '%') -Global -Quiet + if ($currPathEnvVar -ne 'PATH') { + Remove-Path -Path ('%' + $currPathEnvVar + '%') -Global -Quiet + Set-EnvVar -Name $currPathEnvVar -Global -Quiet + } + } + } + } + } +} + function setup_proxy() { # note: '@' and ':' in password must be escaped, e.g. 'p@ssword' -> p\@ssword' $proxy = get_config PROXY @@ -303,7 +374,7 @@ function filesize($length) { } else { if ($null -eq $length) { $length = 0 - } + } "$($length) B" } } @@ -1350,6 +1421,13 @@ $globaldir = $env:SCOOP_GLOBAL, (get_config GLOBAL_PATH), "$([System.Environment # Use at your own risk. $cachedir = $env:SCOOP_CACHE, (get_config CACHE_PATH), "$scoopdir\cache" | Where-Object { $_ } | Select-Object -First 1 | Get-AbsolutePath +# Scoop apps' PATH Environment Variable +$scoopPathEnvVar = switch (get_config USE_ISOLATED_PATH) { + { $_ -is [string] } { $_.ToUpperInvariant() } + $true { 'SCOOP_PATH' } + default { 'PATH' } +} + # OS information $WindowsBuild = [System.Environment]::OSVersion.Version.Build diff --git a/lib/install.ps1 b/lib/install.ps1 index 19f8d9b3f6..12d4220015 100644 --- a/lib/install.ps1 +++ b/lib/install.ps1 @@ -906,19 +906,11 @@ function env_add_path($manifest, $dir, $global, $arch) { $env_add_path = arch_specific 'env_add_path' $manifest $arch $dir = $dir.TrimEnd('\') if ($env_add_path) { - # GH-3785: Add path in ascending order. - [Array]::Reverse($env_add_path) - $env_add_path | Where-Object { $_ } | ForEach-Object { - if ($_ -eq '.') { - $path_dir = $dir - } else { - $path_dir = Join-Path $dir $_ - } - if (!(is_in_dir $dir $path_dir)) { - abort "Error in manifest: env_add_path '$_' is outside the app directory." - } - Add-Path -Path $path_dir -Global:$global -Force + if (get_config USE_ISOLATED_PATH) { + Add-Path -Path ('%' + $scoopPathEnvVar + '%') -Global:$global } + $path = $env_add_path.Where({ $_ }).ForEach({ Join-Path $dir $_ | Get-AbsolutePath }).Where({ is_in_dir $dir $_ }) + Add-Path -Path $path -TargetEnvVar $scoopPathEnvVar -Global:$global -Force } } @@ -926,14 +918,9 @@ function env_rm_path($manifest, $dir, $global, $arch) { $env_add_path = arch_specific 'env_add_path' $manifest $arch $dir = $dir.TrimEnd('\') if ($env_add_path) { - $env_add_path | Where-Object { $_ } | ForEach-Object { - if ($_ -eq '.') { - $path_dir = $dir - } else { - $path_dir = Join-Path $dir $_ - } - Remove-Path -Path $path_dir -Global:$global - } + $path = $env_add_path.Where({ $_ }).ForEach({ Join-Path $dir $_ | Get-AbsolutePath }).Where({ is_in_dir $dir $_ }) + Remove-Path -Path $path -Global:$global # TODO: Remove after forced isolating Scoop path + Remove-Path -Path $path -TargetEnvVar $scoopPathEnvVar -Global:$global } } diff --git a/lib/system.ps1 b/lib/system.ps1 index 2a572a9fde..affe2c5450 100644 --- a/lib/system.ps1 +++ b/lib/system.ps1 @@ -73,63 +73,79 @@ function Set-EnvVar { Publish-EnvVar } -function Test-PathLikeEnvVar { +function Split-PathLikeEnvVar { param( - [string]$Name, + [string[]]$Pattern, [string]$Path ) if ($null -eq $Path -and $Path -eq '') { - return $false, $null + return $null, $null } else { - $strippedPath = $Path.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries).Where({ $_ -ne $Name }) -join ';' - return ($strippedPath -ne $Path), $strippedPath + $splitPattern = $Pattern.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) + $splitPath = $Path.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) + $inPath = @() + foreach ($p in $splitPattern) { + $inPath += $splitPath.Where({ $_ -like $p }) + $splitPath = $splitPath.Where({ $_ -notlike $p }) + } + return ($inPath -join ';'), ($splitPath -join ';') } } function Add-Path { param( - [string]$Path, + [string[]]$Path, + [string]$TargetEnvVar = 'PATH', [switch]$Global, - [switch]$Force + [switch]$Force, + [switch]$Quiet ) - if (!$Path.Contains('%')) { - $Path = Get-AbsolutePath $Path - } # future sessions - $inPath, $strippedPath = Test-PathLikeEnvVar $Path (Get-EnvVar -Name 'PATH' -Global:$Global) + $inPath, $strippedPath = Split-PathLikeEnvVar $Path (Get-EnvVar -Name $TargetEnvVar -Global:$Global) if (!$inPath -or $Force) { - Write-Output "Adding $(friendly_path $Path) to $(if ($Global) {'global'} else {'your'}) path." - Set-EnvVar -Name 'PATH' -Value (@($Path, $strippedPath) -join ';') -Global:$Global + if (!$Quiet) { + $Path | ForEach-Object { + Write-Host "Adding $(friendly_path $_) to $(if ($Global) {'global'} else {'your'}) path." + } + } + Set-EnvVar -Name $TargetEnvVar -Value ((@($Path) + $strippedPath) -join ';') -Global:$Global } # current session - $inPath, $strippedPath = Test-PathLikeEnvVar $Path $env:PATH + $inPath, $strippedPath = Split-PathLikeEnvVar $Path $env:PATH if (!$inPath -or $Force) { - $env:PATH = @($Path, $strippedPath) -join ';' + $env:PATH = (@($Path) + $strippedPath) -join ';' } } function Remove-Path { param( - [string]$Path, - [switch]$Global + [string[]]$Path, + [string]$TargetEnvVar = 'PATH', + [switch]$Global, + [switch]$Quiet, + [switch]$PassThru ) - if (!$Path.Contains('%')) { - $Path = Get-AbsolutePath $Path - } # future sessions - $inPath, $strippedPath = Test-PathLikeEnvVar $Path (Get-EnvVar -Name 'PATH' -Global:$Global) + $inPath, $strippedPath = Split-PathLikeEnvVar $Path (Get-EnvVar -Name $TargetEnvVar -Global:$Global) if ($inPath) { - Write-Output "Removing $(friendly_path $Path) from $(if ($Global) {'global'} else {'your'}) path." - Set-EnvVar -Name 'PATH' -Value $strippedPath -Global:$Global + if (!$Quiet) { + $Path | ForEach-Object { + Write-Host "Removing $(friendly_path $_) from $(if ($Global) {'global'} else {'your'}) path." + } + } + Set-EnvVar -Name $TargetEnvVar -Value $strippedPath -Global:$Global } # current session - $inPath, $strippedPath = Test-PathLikeEnvVar $Path $env:PATH - if ($inPath) { + $inSessionPath, $strippedPath = Split-PathLikeEnvVar $Path $env:PATH + if ($inSessionPath) { $env:PATH = $strippedPath } + if ($PassThru) { + return $inPath + } } ## Deprecated functions @@ -145,8 +161,8 @@ function env($name, $global, $val) { } function strip_path($orig_path, $dir) { - Show-DeprecatedWarning $MyInvocation 'Test-PathLikeEnvVar' - Test-PathLikeEnvVar -Name $dir -Path $orig_path + Show-DeprecatedWarning $MyInvocation 'Split-PathLikeEnvVar' + Split-PathLikeEnvVar -Name $dir -Path $orig_path } function add_first_in_path($dir, $global) { diff --git a/libexec/scoop-config.ps1 b/libexec/scoop-config.ps1 index dbc1925305..ff0bff3eee 100644 --- a/libexec/scoop-config.ps1 +++ b/libexec/scoop-config.ps1 @@ -115,6 +115,11 @@ # Nightly version is formatted as 'nightly-yyyyMMdd' and will be updated after one day if this is set to $true. # Otherwise, nightly version will not be updated unless `--force` is used. # +# use_isolated_path: $true|$false|[string] +# When set to $true, Scoop will use `SCOOP_PATH` environment variable to store apps' `PATH`s. +# When set to arbitrary non-empty string, Scoop will use that string as the environment variable name instead. +# This is useful when you want to isolate Scoop from the system `PATH`. +# # ARIA2 configuration # ------------------- # diff --git a/libexec/scoop-reset.ps1 b/libexec/scoop-reset.ps1 index e073cd4dfb..aeef05cc83 100644 --- a/libexec/scoop-reset.ps1 +++ b/libexec/scoop-reset.ps1 @@ -80,6 +80,9 @@ $apps | ForEach-Object { $dir = link_current $dir create_shims $manifest $dir $global $architecture create_startmenu_shortcuts $manifest $dir $global $architecture + # unset all potential old env before re-adding + env_rm_path $manifest $dir $global $architecture + env_rm $manifest $global $architecture env_add_path $manifest $dir $global $architecture env_set $manifest $dir $global $architecture # unlink all potential old link before re-persisting diff --git a/test/Scoop-Install.Tests.ps1 b/test/Scoop-Install.Tests.ps1 index 130d6a7517..966c2c0c50 100644 --- a/test/Scoop-Install.Tests.ps1 +++ b/test/Scoop-Install.Tests.ps1 @@ -46,13 +46,10 @@ Describe 'env add and remove path' -Tag 'Scoop', 'Windows' { BeforeAll { # test data $manifest = @{ - 'env_add_path' = @('foo', 'bar') + 'env_add_path' = @('foo', 'bar', '.', '..') } $testdir = Join-Path $PSScriptRoot 'path-test-directory' $global = $false - - # store the original path to prevent leakage of tests - $origPath = $env:PATH } It 'should concat the correct path' { @@ -61,12 +58,16 @@ Describe 'env add and remove path' -Tag 'Scoop', 'Windows' { # adding env_add_path $manifest $testdir $global - Assert-MockCalled Add-Path -Times 1 -ParameterFilter { $Path -like "$testdir\foo" } - Assert-MockCalled Add-Path -Times 1 -ParameterFilter { $Path -like "$testdir\bar" } + Should -Invoke -CommandName Add-Path -Times 1 -ParameterFilter { $Path -like "$testdir\foo" } + Should -Invoke -CommandName Add-Path -Times 1 -ParameterFilter { $Path -like "$testdir\bar" } + Should -Invoke -CommandName Add-Path -Times 1 -ParameterFilter { $Path -like $testdir } + Should -Invoke -CommandName Add-Path -Times 0 -ParameterFilter { $Path -like $PSScriptRoot } env_rm_path $manifest $testdir $global - Assert-MockCalled Remove-Path -Times 1 -ParameterFilter { $Path -like "$testdir\foo" } - Assert-MockCalled Remove-Path -Times 1 -ParameterFilter { $Path -like "$testdir\bar" } + Should -Invoke -CommandName Remove-Path -Times 1 -ParameterFilter { $Path -like "$testdir\foo" } + Should -Invoke -CommandName Remove-Path -Times 1 -ParameterFilter { $Path -like "$testdir\bar" } + Should -Invoke -CommandName Remove-Path -Times 1 -ParameterFilter { $Path -like $testdir } + Should -Invoke -CommandName Remove-Path -Times 0 -ParameterFilter { $Path -like $PSScriptRoot } } }