diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 23e5011..d5a3ed9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `supply-chain init`: PowerShell profile injection is now supported. On machines where PowerShell (`pwsh`) is detected, `init` writes wrapper functions into the `CurrentUserAllHosts` profile (`~/.config/powershell/profile.ps1` on Unix/macOS, `Documents\PowerShell\profile.ps1` for pwsh on Windows, or `Documents\WindowsPowerShell\profile.ps1` for Windows PowerShell 5.1). Package managers with dotted names (e.g. `pip3.12`) are skipped in the PowerShell profile — PowerShell function names may not contain dots — and a muted note explains the skip, listing any non-dotted alternatives that are still wrapped. (PPSC-1065) - `supply-chain`: a wrapped install that fails right after the age filter now names the likely culprit instead of a generic note. The proxy is graph-blind — when it withholds a brand-new release and repoints `latest` to an older version, that older version may no longer satisfy a dependent's range, and npm/pnpm/bun/yarn then reject the install. On the npm family a deterministic post-install, one-hop constraint check reports exactly which dependency became unsatisfiable and which package required it (e.g. `scheduler has no version older than the 3-day policy that satisfies ^0.24.0 (required by react-dom)`). The check reads dependency ranges npm already embeds in the metadata the proxy fetched — it is one hop, advisory, and recover-guarded so it can never affect the finished install; multi-hop chains and full resolution are out of scope. pip/uv get the blocked-package name plus a pointer to `uv tree`/`pipdeptree` (PyPI's Simple API carries no dependency ranges). The failure note leads with the protection rationale and lays out remediation surgical-first (allow one package → team exception → relax window), deliberately omitting the global kill switch so a frustrated developer isn't nudged to the blunt instrument. (PPSC-984) - `supply-chain`: a machine-readable compliance report for audit trails ("prove no young package entered this build"). Set `ARMIS_SUPPLY_CHAIN_REPORT=` for a wrapped install (`-` writes to stderr), or pass `--report ` to `supply-chain check`. The JSON carries the effective policy, the enforcement mode (`proxy`/`pre-install`/`check`), and the `checked`/`blocked`/`resolved`/`warned_through`/`conflicts` sets plus `install_status`, so CI can gate with `jq`. A wrap report uses an env var, not a flag, because the wrapped command forwards every flag verbatim to the underlying package manager. (PPSC-984) - `supply-chain`: an opt-in `transitive-policy: warn` config key (and `ARMIS_SUPPLY_CHAIN_TRANSITIVE=warn` env override for the wrap path) that lets a young **transitive** dependency through with a warning instead of failing the build, while still hard-blocking young **direct** dependencies. The default stays `block` — no posture change without opt-in. Direct vs. transitive is determined by reading the root `package.json` (npm family only); if the direct set can't be determined the proxy fails safe and treats every package as direct (blocks). Each warned-through package is printed and marked in the compliance report so security teams can audit exactly which freshly-published packages entered the build. Residual risk and scope are documented in `docs/FEATURES.md`. (PPSC-984) diff --git a/internal/cmd/supply_chain_init.go b/internal/cmd/supply_chain_init.go index 128dfed..dd42180 100644 --- a/internal/cmd/supply_chain_init.go +++ b/internal/cmd/supply_chain_init.go @@ -37,7 +37,8 @@ registry responses. poetry, pipenv, pdm, mvn, and gradle use a pre-install check that blocks the build if violations are found. Four modes are available: - rc — Inject shell functions into ~/.bashrc / ~/.zshrc (default, interactive) + rc — Inject shell functions into ~/.bashrc / ~/.zshrc / fish config / + PowerShell profile (default, interactive) env — Print an eval command for CI or manual sourcing npmrc — Add a marker comment to .npmrc (the registry override itself is set dynamically by 'supply-chain wrap'; use with the rc or env modes) @@ -297,6 +298,35 @@ func summarizeDetectedPMs(s *output.Styles, pms []string) string { return strings.Join(parts, ", ") } +// powerShellSkippedDottedPMs returns the package-manager names in pms that a +// PowerShell wrapper cannot define, because PowerShell function names may not +// contain a dot (e.g. pip3.12). It returns nil unless at least one detected +// shell is PowerShell — the limitation only matters when a PowerShell wrapper is +// actually being written, and on a bash/zsh/fish-only machine the dotted variant +// is wrapped normally. The init flow uses this to print a one-line note so a +// PowerShell user understands pip3.12 was skipped while pip and pip3 still cover +// the common case (the runtime canonicalizes every pip variant to PyPI anyway). +func powerShellSkippedDottedPMs(pms []string, shells []supplychain.Shell) []string { + hasPowerShell := false + for _, sh := range shells { + if supplychain.IsPowerShell(sh.Name) { + hasPowerShell = true + break + } + } + if !hasPowerShell { + return nil + } + + var dotted []string + for _, pm := range pms { + if strings.Contains(pm, ".") { + dotted = append(dotted, pm) + } + } + return dotted +} + // promptYesNo asks the user a yes/no question and reports their answer. // // On an interactive terminal it renders a themed huh.Confirm, matching the @@ -443,7 +473,7 @@ func runInitRC(pms []string) error { shells := supplychain.DetectShells() if len(shells) == 0 { - return fmt.Errorf("no supported shells detected (bash, zsh, or fish)") + return fmt.Errorf("no supported shells detected (bash, zsh, fish, or PowerShell)") } // Short-circuit: if every detected shell already has the exact wrapper we @@ -480,6 +510,27 @@ func runInitRC(pms []string) error { fmt.Fprintf(os.Stderr, "%s %s\n\n", s.MutedText.Render("Package manager(s) to wrap:"), summarizeDetectedPMs(s, pms)) + // PowerShell cannot define functions whose name contains a dot, so a versioned + // pip variant (pip3.12) is skipped in the PowerShell profile. Surface that as a + // single muted line — only when a PowerShell shell is among those detected and a + // dotted variant is actually present — so the user knows the skip is intentional + // and that pip/pip3 still cover the common case. + if dotted := powerShellSkippedDottedPMs(pms, shells); len(dotted) > 0 { + var nonDottedPip []string + for _, pm := range pms { + if (pm == "pip" || pm == "pip3") && !strings.Contains(pm, ".") { + nonDottedPip = append(nonDottedPip, pm) + } + } + var suffix string + if len(nonDottedPip) > 0 { + suffix = fmt.Sprintf("; %s are wrapped instead", strings.Join(nonDottedPip, " and ")) + } + fmt.Fprintf(os.Stderr, "%s\n\n", s.MutedText.Render(fmt.Sprintf( + "Note: %s can't be wrapped in PowerShell (dotted function names are unsupported)%s.", + strings.Join(dotted, ", "), suffix))) + } + // Preview each distinct wrapper. bash/zsh share the posix wrapper while fish // uses different syntax, so group shells by the wrapper they produce to keep // the preview accurate when multiple shells are detected. @@ -524,7 +575,7 @@ func runInitRC(pms []string) error { fmt.Fprintf(os.Stderr, "\n%s Restart your shell or run:\n", s.SuccessText.Render("Done!")) for _, sh := range shells { - fmt.Fprintf(os.Stderr, " %s\n", s.Bold.Render("source "+sh.RCFile)) + fmt.Fprintf(os.Stderr, " %s\n", s.Bold.Render(supplychain.ShellReloadCommand(sh.Name, sh.RCFile))) } policy := resolveWrapPolicy() fmt.Fprintf(os.Stderr, "\n%s block packages published less than %s ago\n", s.MutedText.Render("Policy:"), policy.MinReleaseAge) diff --git a/internal/cmd/supply_chain_init_test.go b/internal/cmd/supply_chain_init_test.go index 1a01a25..146feb9 100644 --- a/internal/cmd/supply_chain_init_test.go +++ b/internal/cmd/supply_chain_init_test.go @@ -404,6 +404,72 @@ func TestSummarizeDetectedPMs(t *testing.T) { } } +// TestPowerShellSkippedDottedPMs verifies the helper that drives the init-time +// note about pip variants PowerShell can't wrap: it reports dotted PM names only +// when a PowerShell shell is among those detected, and stays silent on a +// POSIX/fish-only machine (where dotted variants wrap fine). +func TestPowerShellSkippedDottedPMs(t *testing.T) { + pwsh := []supplychain.Shell{{Name: "pwsh", RCFile: "/p/profile.ps1"}} + posix := []supplychain.Shell{{Name: "bash", RCFile: "/h/.bashrc"}} + mixed := []supplychain.Shell{ + {Name: "zsh", RCFile: "/h/.zshrc"}, + {Name: "powershell", RCFile: "/p/profile.ps1"}, + } + + tests := []struct { + name string + pms []string + shells []supplychain.Shell + want []string + }{ + { + name: "powershell detected with a dotted variant", + pms: []string{"npm", "pip", "pip3", "pip3.12"}, + shells: pwsh, + want: []string{"pip3.12"}, + }, + { + name: "multiple dotted variants are all reported", + pms: []string{"pip3.11", "pip3.12"}, + shells: pwsh, + want: []string{"pip3.11", "pip3.12"}, + }, + { + name: "powershell among mixed shells still reports", + pms: []string{"pip", "pip3.10"}, + shells: mixed, + want: []string{"pip3.10"}, + }, + { + name: "no powershell means no note even with a dotted variant", + pms: []string{"pip", "pip3.12"}, + shells: posix, + want: nil, + }, + { + name: "powershell but no dotted variant is silent", + pms: []string{"npm", "pip", "pip3"}, + shells: pwsh, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := powerShellSkippedDottedPMs(tt.pms, tt.shells) + if len(got) != len(tt.want) { + t.Fatalf("powerShellSkippedDottedPMs = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("powerShellSkippedDottedPMs = %v, want %v", got, tt.want) + break + } + } + }) + } +} + func TestExtractScope(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/supply_chain_status.go b/internal/cmd/supply_chain_status.go index 4d060ec..2ebb400 100644 --- a/internal/cmd/supply_chain_status.go +++ b/internal/cmd/supply_chain_status.go @@ -375,7 +375,7 @@ func runSupplyChainStatusJSON(dir string) error { result.Ecosystems = []statusEcosystemJSON{} } - // armis:ignore cwe:770 reason:DetectShells returns at most one entry per known shell (bash/zsh/fish); the result set is bounded by a fixed allowlist, not by attacker input + // armis:ignore cwe:770 reason:DetectShells returns at most one entry per known shell (bash/zsh/fish/powershell); the result set is bounded by a fixed allowlist, not by attacker input shells := supplychain.DetectShells() for _, sh := range shells { active := supplychain.HasInjection(sh.RCFile) diff --git a/internal/cmd/supply_chain_uninit.go b/internal/cmd/supply_chain_uninit.go index 8768df1..737f32c 100644 --- a/internal/cmd/supply_chain_uninit.go +++ b/internal/cmd/supply_chain_uninit.go @@ -19,7 +19,7 @@ var scUninitCmd = &cobra.Command{ Short: "Remove shell wrapper functions injected by supply-chain init", Long: `Remove the changes made by 'armis-cli supply-chain init'. -This scans your shell RC files (bashrc, zshrc, fish config) for armis-cli supply-chain +This scans your shell RC files (bashrc, zshrc, fish config, PowerShell profile) for armis-cli supply-chain blocks and removes them, and strips the marker comment from a project .npmrc in the current directory if present. Your package manager will return to its normal behavior. diff --git a/internal/supplychain/shell.go b/internal/supplychain/shell.go index 1906129..2211bb3 100644 --- a/internal/supplychain/shell.go +++ b/internal/supplychain/shell.go @@ -30,9 +30,11 @@ const goosWindows = "windows" // Shell name constants used by DetectShells and GenerateWrapper. const ( - shellBash = "bash" - shellZsh = "zsh" - shellFish = "fish" + shellBash = "bash" + shellZsh = "zsh" + shellFish = "fish" + shellPwsh = "pwsh" // PowerShell 7+ (cross-platform) + shellWindowsPowerShell = "powershell" // Windows PowerShell 5.1 ) // pipExeBase is the bare pip executable name. DetectPipVariants falls back to @@ -111,19 +113,116 @@ func DetectShells() []Shell { } } + // PowerShell detection runs as a separate pass. On a POSIX machine $SHELL is + // never "pwsh" and the profile rarely exists, so this yields nothing unless + // the user actually installed PowerShell; on Windows $SHELL is unset and the + // POSIX RC files above don't exist, so the loop above contributes nothing and + // this pass is the only source of shells. Inclusion rule: a PowerShell entry + // qualifies if its executable is on PATH (the analogue of the POSIX + // current-shell check) OR its profile already exists (the analogue of the + // fileExists check) — the profile commonly does not exist until first init. + for _, ps := range powershellProfiles(home) { + if IsOnPath(ps.Name) || fileExists(ps.RCFile) { + shells = append(shells, ps) + } + } + return shells } +// powershellProfiles returns the candidate PowerShell profile targets for the +// current OS. The profile path is the CurrentUserAllHosts profile — host- +// agnostic so the wrapper fires in both the console host and the VS Code +// integrated terminal. Go cannot read PowerShell's own $PROFILE (that requires +// a PowerShell process), so the path is reconstructed from well-known layouts. +func powershellProfiles(home string) []Shell { + if runtime.GOOS != goosWindows { + // On macOS/Linux only PowerShell 7+ (pwsh) exists; its CurrentUserAllHosts + // profile lives under ~/.config/powershell. Build path + Shell on one line + // so the suppression lands on the single taint sink (see Windows branch). + // armis:ignore cwe:22 cwe:73 reason:home is the current user's own $HOME joined with hardcoded path segments; configuring the user's own shell profile is the purpose of `supply-chain init` + return []Shell{{Name: shellPwsh, RCFile: filepath.Join(home, ".config", "powershell", "profile.ps1")}} + } + + // On Windows both editions can be present: PowerShell 7+ (pwsh) and Windows + // PowerShell 5.1 (powershell). They use distinct Documents subdirectories. + // List pwsh first so it is preferred when both are installed. The filepath.Join + // is kept inline in the Shell literal on a single line so the taint flow + // (docs → Join → Shell) has one sink line the suppression sits directly above. + docs := resolveWindowsDocumentsDir(home) + // armis:ignore cwe:22 cwe:73 reason:docs derives from the current user's own $HOME/Documents (or OneDrive redirect) joined with hardcoded segments; configuring the user's own PowerShell profile is the purpose of `supply-chain init` + pwsh := Shell{Name: shellPwsh, RCFile: filepath.Join(docs, "PowerShell", "profile.ps1")} + // armis:ignore cwe:22 cwe:73 reason:docs derives from the current user's own $HOME/Documents (or OneDrive redirect) joined with hardcoded segments; configuring the user's own PowerShell profile is the purpose of `supply-chain init` + win := Shell{Name: shellWindowsPowerShell, RCFile: filepath.Join(docs, "WindowsPowerShell", "profile.ps1")} + return []Shell{pwsh, win} +} + +// resolveWindowsDocumentsDir locates the user's Documents directory, which is +// where PowerShell anchors the CurrentUserAllHosts profile. A native Go process +// cannot read PowerShell's $PROFILE, and OneDrive folder redirection commonly +// moves Documents out of %USERPROFILE%, so the path is resolved heuristically: +// +// 1. /Documents if it already exists (the un-redirected default). +// 2. else /Documents for the first OneDrive env var that yields +// an existing directory (OneDriveConsumer, OneDrive, OneDriveCommercial). +// 3. else /Documents as a not-yet-existing fallback — injectIntoFile's +// os.MkdirAll creates the parent on write, so a missing target is fine. +func resolveWindowsDocumentsDir(home string) string { + // armis:ignore cwe:22 cwe:73 reason:home is the current user's own $HOME joined with the hardcoded "Documents" segment; configuring the user's own profile location is the purpose of `supply-chain init` + defaultDocs := filepath.Join(home, "Documents") + if fileExists(defaultDocs) { + return defaultDocs + } + + for _, env := range []string{"OneDriveConsumer", "OneDrive", "OneDriveCommercial"} { + root := os.Getenv(env) + if root == "" { + continue + } + // armis:ignore cwe:22 cwe:73 reason:root is the user's own OneDrive root from their environment joined with the hardcoded "Documents" segment; resolving the user's own profile location is the purpose of `supply-chain init` + docs := filepath.Join(root, "Documents") + if fileExists(docs) { + return docs + } + } + + return defaultDocs +} + func GenerateWrapper(shell string, pms []string) string { cli := resolveCliPath() switch shell { case shellFish: return generateFishWrapper(pms, cli) + case shellPwsh, shellWindowsPowerShell: + return generatePowerShellWrapper(pms, cli) default: return generatePosixWrapper(pms, cli) } } +// ShellReloadCommand returns the command a user runs to load the freshly-written +// wrapper block into their current session without restarting the shell. POSIX +// shells and fish source the file (`source `); PowerShell dot-sources it +// (`. `). init prints this per detected shell after injection. +func ShellReloadCommand(shell, rcFile string) string { + switch shell { + case shellPwsh, shellWindowsPowerShell: + // armis:ignore cwe:78 cwe:77 cwe:94 reason:rcFile derives from powershellProfiles() which joins $HOME/Documents with hardcoded path segments; the return value is printed to stderr as a human-readable suggestion and never executed programmatically + return ". " + shellQuotePowerShell(rcFile) + default: + return "source " + rcFile + } +} + +// IsPowerShell reports whether name is one of the PowerShell editions (pwsh or +// Windows PowerShell). It lets the cmd layer reason about PowerShell-specific +// guidance (e.g. the dotted-pip-variant note) without hardcoding the shell-name +// literals that this package owns. +func IsPowerShell(name string) bool { + return name == shellPwsh || name == shellWindowsPowerShell +} + // generatePosixWrapper builds the bash/zsh wrapper block for the given PMs. // armis:ignore cwe:770 reason:sanitizePMNames caps the name list at maxPMNames (16), so the string builder cannot grow without bound; pms also originates from local lockfile detection (≤4 ecosystems) rather than untrusted input func generatePosixWrapper(pms []string, cli string) string { @@ -193,10 +292,68 @@ func generateFishWrapper(pms []string, cli string) string { return b.String() } +// generatePowerShellWrapper builds the PowerShell wrapper block for the given +// PMs. Both PowerShell editions (pwsh, Windows PowerShell) share this output — +// the syntax is identical; only profile-path selection differs in DetectShells. +// armis:ignore cwe:770 reason:sanitizePMNames caps the name list at maxPMNames (16), so the string builder cannot grow without bound; pms also originates from local PATH detection (≤4 ecosystems) rather than untrusted input +func generatePowerShellWrapper(pms []string, cli string) string { + safeCli := shellQuotePowerShell(cli) + var b strings.Builder + b.WriteString(markerStart + "\n") + for _, pm := range sanitizePMNames(pms) { + // PowerShell function names cannot contain a dot, so versioned pip + // variants (pip3.12) cannot be wrapped here. Skip them — `pip` and `pip3` + // cover the common case, and the init command surfaces a one-line note so + // the user knows the dotted variant was left unwrapped rather than silently + // dropped. The non-dotted names still flow through sanitizePMNames above. + if strings.Contains(pm, ".") { + continue + } + // Recursion guard + presence check in one construct: `Get-Command + // -CommandType Application` resolves only external executables, so it never + // re-enters the wrapper function itself (the PowerShell analogue of bash + // `command`). It accepts both a bare name and an absolute path, so unlike + // the POSIX wrapper it needs no separate Test-Path branch. + // + // Fail-closed: when armis-cli is not resolvable (e.g. a stale absolute path + // left by a package-manager upgrade), warn on real stderr via + // [Console]::Error.WriteLine — not Write-Error/Write-Host, which write to + // the PowerShell error/information streams rather than the process's stderr + // — and run the real package manager unwrapped rather than failing outright. + // + // @args splats the caller's positional arguments. This works because the + // wrapper is a simple function (no [CmdletBinding()]). $__armis_cli uses a + // double-underscore prefix to avoid colliding with a user variable. + // armis:ignore cwe:78 reason:pm is constrained to ^[a-z][a-z0-9-]*$ here (sanitizePMNames plus the dot filter above removes any dotted variant), so no shell metacharacter can reach the script; safeCli is shellQuotePowerShell-escaped; Get-Command is used only for presence detection + fmt.Fprintf(&b, + "function %s {\n"+ + " $__armis_cli = Get-Command %s -CommandType Application -ErrorAction SilentlyContinue\n"+ + " if ($__armis_cli) {\n"+ + " & $__armis_cli.Source supply-chain wrap %s @args\n"+ + " } else {\n"+ + " [Console]::Error.WriteLine('[armis] armis-cli not found - running %s WITHOUT supply-chain enforcement')\n"+ + " & (Get-Command %s -CommandType Application).Source @args\n"+ + " }\n"+ + "}\n", + pm, safeCli, pm, pm, pm) + } + b.WriteString(markerEnd + "\n") + return b.String() +} + func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } +// shellQuotePowerShell wraps s in a PowerShell single-quoted literal, doubling +// any embedded single quote (two single quotes escape one inside a single-quoted +// string). Single quotes suppress all PowerShell interpolation, so the embedded +// path is treated verbatim — the analogue of POSIX shellQuote but with +// PowerShell's distinct escaping rule. +func shellQuotePowerShell(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + // resolveCliPath returns the most upgrade-proof reference to the armis-cli // binary to embed in the generated shell wrapper functions. // diff --git a/internal/supplychain/shell_test.go b/internal/supplychain/shell_test.go index 957bd2e..ecc6b78 100644 --- a/internal/supplychain/shell_test.go +++ b/internal/supplychain/shell_test.go @@ -39,6 +39,77 @@ func TestGenerateWrapper_Fish(t *testing.T) { } } +// TestGenerateWrapper_PowerShell verifies the PowerShell wrapper carries the +// marker block and emits a `function npm {` declaration that routes through +// `supply-chain wrap npm @args`. Both PowerShell editions (pwsh and Windows +// PowerShell) must produce byte-identical output — the syntax is the same and +// only profile-path selection differs in DetectShells. +func TestGenerateWrapper_PowerShell(t *testing.T) { + wrapper := GenerateWrapper(shellPwsh, []string{"npm"}) + + if !strings.Contains(wrapper, markerStart) { + t.Error("missing start marker") + } + if !strings.Contains(wrapper, markerEnd) { + t.Error("missing end marker") + } + if !strings.Contains(wrapper, "function npm {") { + t.Errorf("missing PowerShell function declaration:\n%s", wrapper) + } + if !strings.Contains(wrapper, "supply-chain wrap npm @args") { + t.Errorf("unexpected PowerShell wrapper: %s", wrapper) + } + + // pwsh and powershell are the same script; only the profile path differs. + if other := GenerateWrapper(shellWindowsPowerShell, []string{"npm"}); other != wrapper { + t.Errorf("pwsh and powershell wrappers differ:\npwsh:\n%s\npowershell:\n%s", wrapper, other) + } +} + +// TestGenerateWrapper_PowerShellContainsGuard verifies the PowerShell wrapper is +// fail-closed and recursion-safe: it resolves armis-cli via `Get-Command +// -CommandType Application` (which bypasses the wrapper function itself), warns +// on real stderr via [Console]::Error.WriteLine when the binary is missing, and +// falls back to running the real package manager through its Application +// resolution so an install never silently breaks after an armis-cli upgrade. +func TestGenerateWrapper_PowerShellContainsGuard(t *testing.T) { + wrapper := GenerateWrapper(shellPwsh, []string{"npm"}) + + if !strings.Contains(wrapper, "Get-Command") { + t.Errorf("PowerShell wrapper missing `Get-Command` guard:\n%s", wrapper) + } + if !strings.Contains(wrapper, "-CommandType Application") { + t.Errorf("PowerShell wrapper missing `-CommandType Application` recursion guard:\n%s", wrapper) + } + if !strings.Contains(wrapper, "armis-cli not found") { + t.Errorf("PowerShell wrapper missing the missing-binary warning:\n%s", wrapper) + } + if !strings.Contains(wrapper, "[Console]::Error.WriteLine") { + t.Errorf("PowerShell wrapper must warn on real stderr via [Console]::Error.WriteLine:\n%s", wrapper) + } + if !strings.Contains(wrapper, "& (Get-Command npm -CommandType Application).Source @args") { + t.Errorf("PowerShell wrapper missing the un-wrapped fallback for npm:\n%s", wrapper) + } +} + +// TestGenerateWrapper_PowerShellSkipsDottedNames verifies that a versioned pip +// variant (pip3.12) is omitted from the PowerShell wrapper — PowerShell function +// names cannot contain a dot — while the non-dotted pip and pip3 are still +// emitted as wrapper functions. +func TestGenerateWrapper_PowerShellSkipsDottedNames(t *testing.T) { + wrapper := GenerateWrapper(shellPwsh, []string{"pip", "pip3", "pip3.12"}) + + if strings.Contains(wrapper, "pip3.12") { + t.Errorf("PowerShell wrapper must skip the dotted variant pip3.12:\n%s", wrapper) + } + if !strings.Contains(wrapper, "function pip {") { + t.Errorf("PowerShell wrapper missing `function pip {`:\n%s", wrapper) + } + if !strings.Contains(wrapper, "function pip3 {") { + t.Errorf("PowerShell wrapper missing `function pip3 {`:\n%s", wrapper) + } +} + func TestGenerateWrapper_MultiplePMs(t *testing.T) { wrapper := GenerateWrapper(shellZsh, []string{"npm", "npx"}) @@ -67,7 +138,7 @@ func TestGenerateWrapper_RejectsUnsafeNames(t *testing.T) { "npm'quote", // embedded quote } - for _, shell := range []string{shellBash, shellZsh, shellFish} { + for _, shell := range []string{shellBash, shellZsh, shellFish, shellPwsh, shellWindowsPowerShell} { for _, name := range malicious { wrapper := GenerateWrapper(shell, []string{name}) // The only content should be the marker block; the unsafe name must @@ -488,12 +559,133 @@ func TestDetectShells(t *testing.T) { t.Run("no RC files and no SHELL yields nothing", func(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("SHELL", "") + t.Setenv("PATH", "") // prevent pwsh on the runner's PATH from triggering PowerShell detection if shells := DetectShells(); len(shells) != 0 { t.Errorf("expected no shells for an empty home with no $SHELL, got %v", shells) } }) } +// TestDetectShells_PowerShell verifies pwsh is detected when its executable is on +// PATH even though no profile file exists yet (the common first-run case). It is +// gated to non-Windows because it seeds a POSIX-style $PATH and relies on the +// non-Windows powershellProfiles layout (~/.config/powershell). +func TestDetectShells_PowerShell(t *testing.T) { + if runtime.GOOS == goosWindows { + t.Skip("seeds a POSIX $PATH and the ~/.config/powershell layout; Windows uses a different profile path") + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "") // no POSIX shell, so only the PowerShell pass can match + + // Seed a pwsh executable on PATH; its profile is deliberately absent. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, shellPwsh), []byte{}, 0o755); err != nil { //nolint:gosec + t.Fatalf("seed pwsh: %v", err) + } + t.Setenv("PATH", dir) + + shells := DetectShells() + var found bool + for _, sh := range shells { + if sh.Name == shellPwsh { + found = true + // The profile path is the host-agnostic CurrentUserAllHosts profile. + want := filepath.Join(home, ".config", "powershell", "profile.ps1") + if sh.RCFile != want { + t.Errorf("pwsh RCFile = %q, want %q", sh.RCFile, want) + } + } + } + if !found { + t.Errorf("expected pwsh among detected shells when on PATH, got %v", shells) + } +} + +// TestDetectShells_PowerShellProfileExists verifies pwsh is detected when its +// profile file already exists even though the executable is not on PATH — the +// profile's presence alone qualifies it. Non-Windows-gated because it depends on +// the ~/.config/powershell layout and a POSIX-style empty $PATH. +func TestDetectShells_PowerShellProfileExists(t *testing.T) { + if runtime.GOOS == goosWindows { + t.Skip("depends on the ~/.config/powershell profile layout, which is Unix-only") + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "") + t.Setenv("PATH", t.TempDir()) // empty dir: pwsh is NOT on PATH + + // Create the profile so detection must qualify it via fileExists, not PATH. + profileDir := filepath.Join(home, ".config", "powershell") + if err := os.MkdirAll(profileDir, 0o750); err != nil { + t.Fatalf("mkdir profile dir: %v", err) + } + profile := filepath.Join(profileDir, "profile.ps1") + if err := os.WriteFile(profile, []byte("# profile\n"), 0o600); err != nil { + t.Fatalf("seed profile: %v", err) + } + + shells := DetectShells() + var found bool + for _, sh := range shells { + if sh.Name == shellPwsh && sh.RCFile == profile { + found = true + } + } + if !found { + t.Errorf("expected pwsh detected via existing profile, got %v", shells) + } +} + +// TestResolveWindowsDocumentsDir exercises the OneDrive-redirection resolution. +// It is Windows-gated (like TestRemoveFunctions_PreservesPermissions skips +// non-applicable platforms) because the helper only runs on the Windows +// detection path and the Documents/OneDrive layout it resolves is Windows-shaped. +func TestResolveWindowsDocumentsDir(t *testing.T) { + if runtime.GOOS != goosWindows { + t.Skip("resolveWindowsDocumentsDir is only used on the Windows detection path") + } + + t.Run("prefers existing home Documents", func(t *testing.T) { + home := t.TempDir() + docs := filepath.Join(home, "Documents") + if err := os.MkdirAll(docs, 0o750); err != nil { + t.Fatal(err) + } + if got := resolveWindowsDocumentsDir(home); got != docs { + t.Errorf("resolveWindowsDocumentsDir = %q, want %q", got, docs) + } + }) + + t.Run("falls back to OneDrive Documents when home Documents is absent", func(t *testing.T) { + home := t.TempDir() // no Documents subdir here + oneDriveRoot := t.TempDir() // simulate the OneDrive root + t.Setenv("OneDriveConsumer", "") + t.Setenv("OneDrive", oneDriveRoot) + t.Setenv("OneDriveCommercial", "") + odDocs := filepath.Join(oneDriveRoot, "Documents") + if err := os.MkdirAll(odDocs, 0o750); err != nil { + t.Fatal(err) + } + if got := resolveWindowsDocumentsDir(home); got != odDocs { + t.Errorf("resolveWindowsDocumentsDir = %q, want %q", got, odDocs) + } + }) + + t.Run("falls back to home Documents when nothing exists", func(t *testing.T) { + home := t.TempDir() + t.Setenv("OneDriveConsumer", "") + t.Setenv("OneDrive", "") + t.Setenv("OneDriveCommercial", "") + want := filepath.Join(home, "Documents") + if got := resolveWindowsDocumentsDir(home); got != want { + t.Errorf("resolveWindowsDocumentsDir = %q, want %q (not-yet-existing default)", got, want) + } + }) +} + // TestGenerateWrapper_PosixContainsGuard verifies the bash/zsh wrapper is // fail-closed: it guards the armis-cli invocation with a `command -v` PATH lookup // (alongside an `[ -x ]` absolute-path check), warns on stderr when the binary is @@ -568,6 +760,29 @@ func TestGenerateWrapper_AbsolutePathGuardChecksExecutable(t *testing.T) { } } +// TestShellReloadCommand verifies the per-shell reload hint: POSIX shells and +// fish source the file, while both PowerShell editions dot-source it. +func TestShellReloadCommand(t *testing.T) { + const rc = "/home/u/.rc" + tests := []struct { + shell string + want string + }{ + {shellBash, "source " + rc}, + {shellZsh, "source " + rc}, + {shellFish, "source " + rc}, + {shellPwsh, ". '" + rc + "'"}, + {shellWindowsPowerShell, ". '" + rc + "'"}, + } + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + if got := ShellReloadCommand(tt.shell, rc); got != tt.want { + t.Errorf("ShellReloadCommand(%q, %q) = %q, want %q", tt.shell, rc, got, tt.want) + } + }) + } +} + // TestGenerateWrapper_WarningReferencesPMName verifies the fallback warning names // the package manager the user actually invoked (not a hard-coded "npm"), for // every shell and including versioned pip variants. @@ -581,6 +796,19 @@ func TestGenerateWrapper_WarningReferencesPMName(t *testing.T) { } } } + + // PowerShell cannot define dotted function names, so pip3.12 is skipped (covered + // by TestGenerateWrapper_PowerShellSkipsDottedNames). For the names it does wrap, + // the same per-PM warning must name the invoked command. + for _, shell := range []string{shellPwsh, shellWindowsPowerShell} { + for _, pm := range []string{"npm", "pip3", "poetry"} { + wrapper := GenerateWrapper(shell, []string{pm}) + want := "running " + pm + " WITHOUT supply-chain enforcement" + if !strings.Contains(wrapper, want) { + t.Errorf("%s wrapper for %q missing PM name in warning %q:\n%s", shell, pm, want, wrapper) + } + } + } } // TestResolveCliPath_PrefersBareNameWhenOnPath verifies that when armis-cli is @@ -644,6 +872,19 @@ func TestWrappedPMs(t *testing.T) { } }) + t.Run("parses the PMs from a PowerShell block", func(t *testing.T) { + // The wrappedPMLine regex matches `function name {` regardless of the + // trailing brace, so a PowerShell block round-trips through WrappedPMs the + // same way fish does. pip3.12 is skipped by the generator (dotted names are + // unsupported in PowerShell), so it must not appear in the parsed set. + rc := writeRC(t, GenerateWrapper(shellPwsh, []string{"npm", "pip", "pip3.12"})) + got := WrappedPMs(rc) + want := []string{"npm", "pip"} + if !slices.Equal(got, want) { + t.Errorf("WrappedPMs = %v, want %v", got, want) + } + }) + t.Run("ignores user functions outside the injected block", func(t *testing.T) { // A user's own npm function defined before the marker must not be reported // as armis-wrapped — only declarations inside the marked block count.