diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b554bce2f..7355102a0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: src/ tests/ scripts/lint_architecture_boundaries.py scripts/architecture_linter/ + scripts/windows_native_symlink_probe_entry.py - name: Ruff format check run: >- @@ -42,6 +43,7 @@ jobs: src/ tests/ scripts/lint_architecture_boundaries.py scripts/architecture_linter/ + scripts/windows_native_symlink_probe_entry.py - name: Check YAML encoding safety run: | @@ -92,7 +94,8 @@ jobs: --fail-on=R0801 \ src/apm_cli/ \ scripts/lint_architecture_boundaries.py \ - scripts/architecture_linter/ + scripts/architecture_linter/ \ + scripts/windows_native_symlink_probe_entry.py - name: Lint - auth-protocol boundary (#1212 anti-regression) run: bash scripts/lint-auth-signals.sh @@ -167,6 +170,58 @@ jobs: git config --list --show-origin echo "::endgroup::" + windows-native-standard-user-symlink-gate: + name: Windows Native Symlink Acceptance + runs-on: windows-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: 'false' + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + + - name: Install dependencies + run: uv sync --frozen --extra dev + + - name: Prove native standard-user fallback + shell: pwsh + timeout-minutes: 10 + env: + APM_NATIVE_EXPECTED_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: >- + ./scripts/windows_native_standard_user_symlink_gate.ps1 + -ExpectedHead $env:APM_NATIVE_EXPECTED_HEAD + + - name: Restore owned native fixture + if: always() + shell: pwsh + timeout-minutes: 2 + run: ./scripts/windows_native_standard_user_symlink_gate.ps1 -CleanupOnly + + - name: Retain native proof and restoration evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-native-symlink-${{ github.run_attempt }} + path: ${{ runner.temp }}/apm-native-symlink-evidence/ + if-no-files-found: error + retention-days: 30 + windows-git-discovery: name: Windows Git Discovery (Python ${{ matrix.python-version }}) runs-on: windows-latest diff --git a/docs/src/content/docs/consumer/install-packages.md b/docs/src/content/docs/consumer/install-packages.md index 413f1ff63c..23b045ddfb 100644 --- a/docs/src/content/docs/consumer/install-packages.md +++ b/docs/src/content/docs/consumer/install-packages.md @@ -206,6 +206,18 @@ For the full flag reference, run `apm install --help` or see ## When things go wrong +- **Symlink checkout fallback.** If a checked-out file contains only a short + relative path (for example `../shared/config.yml`) instead of real content, + Git substituted a plain-text fallback for a symlink it could not create. + APM preserves Git's detected `core.symlinks` setting instead of letting an + inherited global value override it; this capability check is not + Windows-specific, but Windows non-admin accounts are the common case + without symlink-creation rights. Explicit command-scope Git settings still + take precedence; APM does not change your global Git configuration. A + successful checkout therefore does not guarantee that a package requiring + real symlinks works -- see "Skipped symlinked agent source" below for the + related install-time check on agent sources. Use a package that ships real + source files, or a symlink-capable environment. - **Critical security finding.** Install aborts with the offending characters and file path. Patch upstream when you can; use `--force` only when you can document the exception. diff --git a/docs/src/content/docs/contributing/integration-testing.md b/docs/src/content/docs/contributing/integration-testing.md index 65869c659f..6e0a86614e 100644 --- a/docs/src/content/docs/contributing/integration-testing.md +++ b/docs/src/content/docs/contributing/integration-testing.md @@ -118,6 +118,7 @@ what the test family you want actually requires. | `requires_e2e_mode` | Opt-in for the heavyweight golden-scenario suite | `export APM_E2E_TESTS=1` | | `requires_network_integration` | Opt-in for tests that hit live registries | `export APM_RUN_INTEGRATION_TESTS=1` | | `requires_windows` | A Windows-only process or filesystem boundary | Run on Windows | +| `requires_windows_native_standard_user` | Verified standard-user token without symlink privilege | The disposable Windows native-symlink CI job; do not enable manually | | `requires_inference` | Opt-in for tests that call inference APIs | `export APM_RUN_INFERENCE_TESTS=1` | | `requires_github_token` | A token usable against `github.com` / GitHub Models | `export GITHUB_APM_PAT=...` (or `GITHUB_TOKEN`) | | `requires_ado_pat` | Azure DevOps PAT for ADO host tests | `export ADO_APM_PAT=...` | @@ -456,6 +457,20 @@ fallback; a candidate-count regression checks linear extension scanning. Only a native Windows run demonstrates Windows behavior; passing mocks or collection alone do not. +The **Windows Native Symlink Acceptance** job checks out the exact PR head and +uses a temporary standard account on a disposable hosted runner. It requires +successful ordinary file I/O, no administrator or symlink privilege, and actual +Windows error 1314 before running the Git/APM fallback regression. The test keeps +inherited global `core.symlinks=true` while honoring Git's native local `false`; +explicit command-scope intent remains covered separately. + +The job requires a passing native case, failure after removing the local +precedence guard in memory, and a restored pass. Empty or skipped runs fail. +Account, profile, scratch and any changed Developer Mode value are cleaned up +independently, with an `always()` cleanup fallback. A restoration failure fails +the job. This is link-text checkout evidence, not archive-symlink support, and +the job does not change repository protection or replace required SDL scanning. + Plugin sequential-install coverage checks deployed-file removal and lockfile ownership after uninstall, plus unchanged files and ownership for the retained skill. The local fixture covers both an independent skill and a separately diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index b7beb4a380..aef988cfb7 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -71,6 +71,15 @@ fallback requires an exhausted plan with an executed same-origin effective HTTPS attempt. Path containment rejects symlink or traversal escapes. See [GitLab authentication and fetch policy](authentication.md#gitlab-saas-or-self-managed). +### Git symlink checkout fallback + +APM preserves Git's detected `core.symlinks` setting and explicit command-scope +overrides without changing global Git configuration, so a successful checkout +is not proof that a package requiring real symlinks works (common on non-admin +Windows accounts). See [Install packages: symlink checkout +fallback](https://microsoft.github.io/apm/consumer/install-packages/#when-things-go-wrong) +for the full behavior and troubleshooting steps. + ### Custom git ports Non-default git ports are preserved on `https://`, `http://`, and `ssh://` URLs diff --git a/pyproject.toml b/pyproject.toml index 5a1070eae1..4bedac95ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,6 +212,7 @@ markers = [ "requires_ado_bearer: requires az CLI logged in + APM_TEST_ADO_BEARER=1", "requires_network_integration: requires APM_RUN_INTEGRATION_TESTS=1", "requires_windows: requires a Windows host", + "requires_windows_native_standard_user: requires the disposable Windows runner's verified non-admin token and symlink denial", "requires_apm_binary: requires built apm binary on PATH (or APM_BINARY_PATH)", "requires_runtime_codex: requires codex runtime installed", "requires_runtime_copilot: requires GitHub Copilot CLI runtime installed", diff --git a/scripts/windows_native_standard_user_symlink_gate.ps1 b/scripts/windows_native_standard_user_symlink_gate.ps1 new file mode 100644 index 0000000000..9d69ca4280 --- /dev/null +++ b/scripts/windows_native_standard_user_symlink_gate.ps1 @@ -0,0 +1,290 @@ +param( + [string]$ExpectedHead, + [switch]$CleanupOnly +) + +$ErrorActionPreference = 'Stop' +$RegistryPath = 'SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' +$RegistryValue = 'AllowDevelopmentWithoutDevLicense' + +if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:RUNNER_OS -ne 'Windows') { + throw 'This gate may only mutate its disposable GitHub-hosted Windows runner.' +} +$RunnerTemp = (Resolve-Path -LiteralPath $env:RUNNER_TEMP).Path +$Control = Join-Path $RunnerTemp 'apm-native-symlink-evidence' +$StateFile = Join-Path $Control 'state.json' +$Root = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$RunnerSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + +function Write-State($State) { + $temporary = Join-Path $Control 'state.pending.json' + [System.IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 8)) + Move-Item -LiteralPath $temporary -Destination $StateFile -Force +} + +function Get-DeveloperModeState { + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($RegistryPath) + if ($null -eq $key) { + return [ordered]@{ KeyExists = $false; ValueExists = $false; Kind = $null; Value = $null } + } + try { + $exists = $key.GetValueNames() -contains $RegistryValue + return [ordered]@{ + KeyExists = $true + ValueExists = $exists + Kind = $(if ($exists) { $key.GetValueKind($RegistryValue).ToString() } else { $null }) + Value = $(if ($exists) { $key.GetValue($RegistryValue) } else { $null }) + } + } + finally { $key.Close() } +} + +function Assert-OwnedState($State) { + if ($State.RunId -ne $env:GITHUB_RUN_ID -or $State.RunAttempt -ne $env:GITHUB_RUN_ATTEMPT -or $State.RunnerSid -ne $RunnerSid.Value) { + throw 'Native cleanup state does not belong to this runner invocation.' + } + if ($State.UserName -notmatch '^apmns[a-f0-9]{12}$') { + throw 'Invalid owned account name in cleanup state.' + } + if ([System.IO.Path]::GetDirectoryName($State.Scratch) -ne $RunnerTemp -or + [System.IO.Path]::GetFileName($State.Scratch) -notmatch '^apm-native-[a-f0-9]{32}$') { + throw 'Refusing cleanup outside the exact owned scratch directory.' + } +} + +function Stop-OwnedProcess($State) { + if (-not $State.ChildPid) { return } + $process = Get-Process | Where-Object { $_.Id -eq $State.ChildPid } + if ($null -eq $process) { return } + if ($process.StartTime.ToUniversalTime().Ticks.ToString() -ne $State.ChildStartTicks) { + throw 'The recorded PID was reused; refusing to terminate it.' + } + $process.Kill($true) + if (-not $process.WaitForExit(30000)) { throw 'The owned child process did not terminate.' } +} + +function Restore-DeveloperMode($State) { + $before = $State.RegistryBefore | ConvertTo-Json -Compress + $current = Get-DeveloperModeState + if (($current | ConvertTo-Json -Compress) -eq $before) { return } + if (-not $State.RegistryChanged -or -not $current.ValueExists -or $current.Kind -ne 'DWord' -or $current.Value -ne 0) { + throw 'Developer Mode changed unexpectedly; refusing to overwrite unrelated state.' + } + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($RegistryPath, $true) + try { + $key.SetValue($RegistryValue, [int]$State.RegistryBefore.Value, [Microsoft.Win32.RegistryValueKind]::DWord) + } + finally { $key.Close() } + if (((Get-DeveloperModeState) | ConvertTo-Json -Compress) -ne $before) { + throw 'Developer Mode key/value presence, type or value was not restored.' + } +} + +function Remove-OwnedProfile($State) { + if (-not $State.UserSid) { return } + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$($State.UserSid)'") + if ($profiles.Count -gt 1) { throw 'More than one profile matches the newly created SID.' } + foreach ($profile in $profiles) { + if ($profile.Loaded) { throw 'The owned user profile is still loaded.' } + $profile | Remove-CimInstance + } + if (@(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$($State.UserSid)'").Count) { + throw 'The owned user profile was not removed.' + } +} + +function Remove-OwnedUser($State) { + $users = @(Get-LocalUser | Where-Object { $_.Name -eq $State.UserName }) + if (-not $users.Count) { return } + if ($users.Count -ne 1 -or -not $State.UserSid -or $users[0].SID.Value -ne $State.UserSid) { + throw 'The account identity differs from the recorded created SID; refusing deletion.' + } + Remove-LocalUser -SID $users[0].SID + if (@(Get-LocalUser | Where-Object { $_.Name -eq $State.UserName }).Count) { + throw 'The owned local account was not removed.' + } +} + +function Save-ChildEvidence($State) { + if (-not (Test-Path -LiteralPath $State.Scratch)) { return } + foreach ($name in @('stdout.log', 'stderr.log', 'native-proof.json', 'baseline.xml', 'mutation.xml', 'restored.xml')) { + $source = Join-Path $State.Scratch $name + if (Test-Path -LiteralPath $source -PathType Leaf) { + Copy-Item -LiteralPath $source -Destination (Join-Path $Control $name) -Force + } + } +} + +function Invoke-OwnedCleanup($State) { + Assert-OwnedState $State + $failures = [System.Collections.Generic.List[string]]::new() + $actions = [ordered]@{ + process = { Stop-OwnedProcess $State } + evidence = { Save-ChildEvidence $State } + registry = { Restore-DeveloperMode $State } + profile = { Remove-OwnedProfile $State } + account = { Remove-OwnedUser $State } + scratch = { + if (Test-Path -LiteralPath $State.Scratch) { + Remove-Item -LiteralPath $State.Scratch -Recurse -Force + } + if (Test-Path -LiteralPath $State.Scratch) { throw 'Owned scratch/ACLs remain.' } + } + } + foreach ($entry in $actions.GetEnumerator()) { + try { & $entry.Value } + catch { $failures.Add("$($entry.Key): $($_.Exception.Message)") } + } + $State.CleanupComplete = $failures.Count -eq 0 + $State.CleanupFailures = @($failures) + $State.CleanupHistory = @($State.CleanupHistory) + @([ordered]@{ + At = [DateTime]::UtcNow.ToString('o') + Complete = $State.CleanupComplete + Failures = @($failures) + }) + Write-State $State + if ($failures.Count) { + throw "Native cleanup failed: $($failures -join '; ')" + } + Write-Host '[+] Native account, profile, registry and subject scratch restored.' +} + +if ($CleanupOnly) { + if (-not (Test-Path -LiteralPath $StateFile)) { + Write-Host '[i] No persisted native setup state; no subject resources were created.' + exit 0 + } + $state = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json -AsHashtable + Invoke-OwnedCleanup $state + exit 0 +} + +if ($ExpectedHead -notmatch '^[0-9a-f]{40}$') { throw 'An exact PR/source SHA is required.' } +if (Test-Path -LiteralPath $Control) { throw 'Native evidence directory already exists; refusing reuse.' } +$principal = [System.Security.Principal.WindowsPrincipal]::new([System.Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Privileged fixture setup requires the hosted runner administrator, never the subject user.' +} +$registryBefore = Get-DeveloperModeState +if ($registryBefore.ValueExists -and $registryBefore.Kind -ne 'DWord') { + throw 'Unexpected Developer Mode registry type; no machine changes were made.' +} +$nonce = [Guid]::NewGuid().ToString('N') +$userName = 'apmns' + $nonce.Substring(0, 12) +if (@(Get-LocalUser | Where-Object { $_.Name -eq $userName }).Count) { throw 'Account collision.' } +$scratch = Join-Path $RunnerTemp ('apm-native-' + $nonce) +if (Test-Path -LiteralPath $scratch) { throw 'Scratch collision.' } + +New-Item -ItemType Directory -Path $Control | Out-Null +$controlAcl = [System.Security.AccessControl.DirectorySecurity]::new() +$controlAcl.SetAccessRuleProtection($true, $false) +$controlAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $RunnerSid, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow' +)) +Set-Acl -LiteralPath $Control -AclObject $controlAcl +$state = [ordered]@{ + RunId = $env:GITHUB_RUN_ID + RunAttempt = $env:GITHUB_RUN_ATTEMPT + RunnerSid = $RunnerSid.Value + Head = $ExpectedHead + UserName = $userName + UserSid = $null + Scratch = $scratch + RegistryBefore = $registryBefore + RegistryChanged = $false + ChildPid = $null + ChildStartTicks = $null + ChildExit = $null + Failure = $null + CleanupComplete = $false + CleanupFailures = @() + CleanupHistory = @() +} +Write-State $state +$failure = $null +$cleanupFailure = $null +try { + $bytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(24) + $password = ConvertTo-SecureString ([Convert]::ToBase64String($bytes) + 'aA1!') -AsPlainText -Force + $user = New-LocalUser -Name $userName -Password $password -AccountNeverExpires -PasswordNeverExpires + $state.UserSid = $user.SID.Value + Write-State $state + $usersGroup = Get-LocalGroup -SID 'S-1-5-32-545' + if (-not @(Get-LocalGroupMember -Group $usersGroup | Where-Object { $_.SID -eq $user.SID }).Count) { + Add-LocalGroupMember -Group $usersGroup -Member $user + } + if (@(Get-LocalGroupMember -SID 'S-1-5-32-544' | Where-Object { $_.SID -eq $user.SID }).Count) { + throw 'Created subject unexpectedly belongs to Administrators.' + } + if (@(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$($user.SID.Value)'").Count) { + throw 'A profile existed before the created subject was started.' + } + if ($registryBefore.ValueExists -and $registryBefore.Value -eq 1) { + $state.RegistryChanged = $true + Write-State $state + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($RegistryPath, $true) + try { $key.SetValue($RegistryValue, 0, [Microsoft.Win32.RegistryValueKind]::DWord) } + finally { $key.Close() } + } + New-Item -ItemType Directory -Path $scratch | Out-Null + $scratchAcl = Get-Acl -LiteralPath $scratch + $scratchAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $user.SID, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow' + )) + Set-Acl -LiteralPath $scratch -AclObject $scratchAcl + foreach ($name in @('home', 'temp', 'cache', 'appdata', 'localappdata')) { + New-Item -ItemType Directory -Path (Join-Path $scratch $name) | Out-Null + } + $env:HOME = Join-Path $scratch 'home' + $env:USERPROFILE = $env:HOME + $env:HOMEDRIVE = [System.IO.Path]::GetPathRoot($env:HOME).TrimEnd('\') + $env:HOMEPATH = $env:HOME.Substring($env:HOMEDRIVE.Length) + $env:TEMP = Join-Path $scratch 'temp' + $env:TMP = $env:TEMP + $env:UV_CACHE_DIR = Join-Path $scratch 'cache' + $env:APPDATA = Join-Path $scratch 'appdata' + $env:LOCALAPPDATA = Join-Path $scratch 'localappdata' + $env:PYTHONDONTWRITEBYTECODE = '1' + $env:PYTEST_ADDOPTS = '' + $env:APM_WINDOWS_NATIVE_STANDARD_USER = $null + $env:APM_WINDOWS_NATIVE_MUTATION = $null + $python = Join-Path $Root '.venv\Scripts\python.exe' + $entry = Join-Path $Root 'scripts\windows_native_symlink_probe_entry.py' + $credential = [System.Management.Automation.PSCredential]::new("$env:COMPUTERNAME\$userName", $password) + $arguments = @( + '-B', "`"$entry`"", '--root', "`"$Root`"", '--scratch', "`"$scratch`"", + '--expected-sid', $user.SID.Value, '--expected-head', $ExpectedHead + ) + $process = Start-Process -FilePath $python -ArgumentList $arguments -Credential $credential ` + -WorkingDirectory $scratch -PassThru ` + -RedirectStandardOutput (Join-Path $scratch 'stdout.log') ` + -RedirectStandardError (Join-Path $scratch 'stderr.log') + $process.Handle | Out-Null + $state.ChildPid = $process.Id + $state.ChildStartTicks = $process.StartTime.ToUniversalTime().Ticks.ToString() + Write-State $state + if (-not $process.WaitForExit(480000)) { throw 'Native subject exceeded its eight-minute timeout.' } + $process.Refresh() + $state.ChildExit = $process.ExitCode + Write-State $state + if ($process.ExitCode -ne 0) { throw "Native subject failed with exit $($process.ExitCode)." } + if (-not (Test-Path -LiteralPath (Join-Path $scratch 'native-proof.json') -PathType Leaf)) { + throw 'The native subject produced no positive proof.' + } +} +catch { + $failure = $_.Exception.Message + $state.Failure = $failure +} +finally { + try { Invoke-OwnedCleanup $state } + catch { $cleanupFailure = $_.Exception.Message } + foreach ($name in @('stdout.log', 'stderr.log')) { + $log = Join-Path $Control $name + if (Test-Path -LiteralPath $log) { Get-Content -LiteralPath $log } + } +} +if ($failure) { Write-Host "[x] $failure" } +if ($cleanupFailure) { Write-Host "[x] $cleanupFailure" } +if ($failure -or $cleanupFailure) { exit 1 } +Write-Host '[+] Native symlink acceptance and independent restoration completed.' diff --git a/scripts/windows_native_symlink_probe_entry.py b/scripts/windows_native_symlink_probe_entry.py new file mode 100644 index 0000000000..b68e361b0c --- /dev/null +++ b/scripts/windows_native_symlink_probe_entry.py @@ -0,0 +1,249 @@ +"""Run the native symlink regression under the disposable runner's standard user.""" + +from __future__ import annotations + +import argparse +import csv +import ctypes +import io +import json +import os +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +from apm_cli.utils.console import _rich_error, _rich_info +from apm_cli.utils.git_env import get_git_executable + +NATIVE_NODE = ( + "tests/integration/test_windows_native_symlink.py::test_native_standard_user_symlink_fallback" +) +MUTATION_FAILURE = "native-symlink-precedence: APM clone failed after native Git fallback succeeded" + + +def _whoami_rows(option: str) -> list[list[str]]: + """Read token information without printing the process environment.""" + executable = Path(os.environ["SYSTEMROOT"]) / "System32" / "whoami.exe" + result = subprocess.run( # noqa: S603 - fixed OS utility, no shell + [str(executable), option, "/fo", "csv", "/nh"], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + return list(csv.reader(io.StringIO(result.stdout))) + + +def probe_native_context(scratch: Path, expected_sid: str) -> dict[str, object]: + """Require an actual writable, non-admin token without symlink privilege.""" + if os.name != "nt": + raise RuntimeError("This acceptance probe requires native Windows") + users = _whoami_rows("/user") + if len(users) != 1 or len(users[0]) != 2 or users[0][1] != expected_sid: + raise RuntimeError("The child token does not match the created standard-user SID") + groups = _whoami_rows("/groups") + privileges = _whoami_rows("/priv") + if not groups or not privileges or any(len(row) < 3 for row in (*groups, *privileges)): + raise RuntimeError("Windows token information is incomplete") + group_sids = [row[2] for row in groups] + privilege_names = [row[0] for row in privileges] + if ctypes.windll.shell32.IsUserAnAdmin() or "S-1-5-32-544" in group_sids: + raise RuntimeError("The acceptance process must not have an administrator token") + if "SeCreateSymbolicLinkPrivilege" in privilege_names: + raise RuntimeError("The acceptance token must lack symlink privilege, even if disabled") + + scratch.mkdir(parents=True, exist_ok=False) + target = scratch / "ordinary.txt" + target.write_bytes(b"ordinary read/write succeeds") + if target.read_bytes() != b"ordinary read/write succeeds": + raise RuntimeError("Ordinary scratch I/O failed") + link = scratch / "link.txt" + try: + os.symlink(target, link) + except OSError as error: + if error.winerror != 1314: + raise RuntimeError( + f"Expected privilege denial 1314, received {error.winerror}" + ) from error + else: + link.unlink() + raise RuntimeError( + "Symlink creation succeeded; this is not the required denied-capability case" + ) + return { + "sid": expected_sid, + "username": users[0][0], + "group_sids": group_sids, + "privileges": privilege_names, + "administrator": False, + "ordinary_io": True, + "symlink_winerror": 1314, + } + + +def check_junit(report: Path, *, mutation: bool) -> int: + """Reject skipped/empty/error results and require the real regression node.""" + # Only read the report from our own bounded pytest subprocess. + cases = ET.parse(report).getroot().findall(".//testcase") # noqa: S314 + native = [ + case + for case in cases + if case.get("name") == NATIVE_NODE.rsplit("::", 1)[1] + and case.get("classname") == "tests.integration.test_windows_native_symlink" + ] + if len(native) != 1 or any( + case.find(tag) is not None for case in cases for tag in ("skipped", "error") + ): + raise ValueError("The native regression must execute exactly once without skips or errors") + failures = [case.find("failure") for case in cases if case.find("failure") is not None] + if mutation: + if len(cases) != 1 or len(failures) != 1: + raise ValueError("The mutation must fail exactly the native regression") + if MUTATION_FAILURE not in (failures[0].text or ""): + raise ValueError("The mutation failed for an unrelated reason") + elif failures: + raise ValueError("The restored native regression and preservation controls must pass") + return len(cases) + + +def source_provenance(root: Path, expected_head: str) -> dict[str, str]: + """Bind imports and Git state to the explicitly checked-out PR source.""" + import apm_cli + + source = Path(apm_cli.__file__).resolve().parent + if source != root / "src" / "apm_cli": + raise RuntimeError(f"APM imported from a different checkout: {source}") + python = Path(sys.executable) + if python.parent != root / ".venv" / "Scripts": + raise RuntimeError( + "The acceptance process must use this checkout's provisioned interpreter" + ) + binary = python.with_name("apm.exe") + if not binary.is_file(): + raise RuntimeError("The provisioned APM executable is missing") + command = [get_git_executable(), "-c", f"safe.directory={root}", "-C", str(root)] + head = subprocess.check_output( # noqa: S603 - resolved Git, fixed argv, no shell + [*command, "rev-parse", "HEAD"], text=True, timeout=30 + ).strip() + status = subprocess.check_output( # noqa: S603 - resolved Git, fixed argv, no shell + [*command, "status", "--porcelain", "--untracked-files=no"], text=True, timeout=30 + ) + if head != expected_head: + raise RuntimeError(f"Expected source head {expected_head}, found {head}") + if status: + paths = status.splitlines() + raise RuntimeError( + f"The tracked acceptance source is dirty ({len(paths)} paths):\n" + + "\n".join(paths[:20]) + ) + os.environ["APM_BINARY_PATH"] = str(binary) + return {"head": head, "python": str(python), "apm_binary": str(binary), "source": str(source)} + + +def run_phase(root: Path, scratch: Path, name: str) -> dict[str, object]: + """Capture a real pytest exit and inspect its machine-readable result.""" + mutation = name == "mutation" + env = dict(os.environ) + if mutation: + env["APM_WINDOWS_NATIVE_MUTATION"] = "drop-local-precedence" + else: + env.pop("APM_WINDOWS_NATIVE_MUTATION", None) + report = scratch / f"{name}.xml" + nodes = [NATIVE_NODE] + if name == "baseline": + nodes.insert(0, "tests/unit/cache/test_git_symlink_config.py") + command = [ + sys.executable, + "-B", + "-m", + "pytest", + "-p", + "no:cacheprovider", + "-o", + "addopts=", + "-o", + "junit_family=xunit1", + "--strict-markers", + "--basetemp", + str(scratch / f"{name}-cases"), + "-vv", + "--tb=short", + "--show-capture=no", + "--no-showlocals", + f"--junitxml={report}", + *nodes, + ] + result = subprocess.run( # noqa: S603 - pinned interpreter and fixed test nodes, no shell + command, cwd=root, env=env, check=False, timeout=180 + ) + expected_exit = 1 if mutation else 0 + if result.returncode != expected_exit: + raise RuntimeError(f"{name}: expected pytest exit {expected_exit}, got {result.returncode}") + count = check_junit(report, mutation=mutation) + if name == "baseline" and count < 2: + raise RuntimeError("The baseline did not execute the preservation controls") + return {"phase": name, "command": command, "exit_code": result.returncode, "cases": count} + + +def main() -> int: + """Qualify the token before enabling the native prerequisite marker.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--scratch", type=Path, required=True) + parser.add_argument("--expected-sid", required=True) + parser.add_argument("--expected-head", required=True) + args = parser.parse_args() + try: + root = args.root.resolve(strict=True) + scratch = args.scratch.resolve(strict=True) + for name in ("home", "temp", "cache", "appdata", "localappdata", "config", "data"): + (scratch / name).mkdir(exist_ok=True) + for key in tuple(os.environ): + if key.startswith("GIT_"): + os.environ.pop(key) + for scope in ("GLOBAL", "SYSTEM"): + config = scratch / f"{scope.lower()}.gitconfig" + config.write_bytes(b"") + os.environ[f"GIT_CONFIG_{scope}"] = str(config) + os.environ.update( + HOME=str(scratch / "home"), + USERPROFILE=str(scratch / "home"), + TMP=str(scratch / "temp"), + TEMP=str(scratch / "temp"), + UV_CACHE_DIR=str(scratch / "cache"), + APPDATA=str(scratch / "appdata"), + LOCALAPPDATA=str(scratch / "localappdata"), + XDG_CACHE_HOME=str(scratch / "cache"), + XDG_CONFIG_HOME=str(scratch / "config"), + XDG_DATA_HOME=str(scratch / "data"), + COVERAGE_FILE=str(scratch / ".coverage"), + HYPOTHESIS_STORAGE_DIRECTORY=str(scratch / "cache" / "hypothesis"), + PYTEST_ADDOPTS="", + PYTHONDONTWRITEBYTECODE="1", + ) + tempfile.tempdir = str(scratch / "temp") + provenance = source_provenance(root, args.expected_head) + context = probe_native_context(scratch / "initial-capability", args.expected_sid) + os.environ["APM_WINDOWS_NATIVE_EXPECTED_SID"] = args.expected_sid + os.environ["APM_WINDOWS_NATIVE_STANDARD_USER"] = "1" + _rich_info("Native standard-user token confirmed; running fallback and mutation controls") + phases = [run_phase(root, scratch, phase) for phase in ("baseline", "mutation", "restored")] + if source_provenance(root, args.expected_head) != provenance: + raise RuntimeError("Source/interpreter provenance changed during acceptance") + proof = {"source": provenance, "context": context, "phases": phases, "status": "passed"} + (scratch / "native-proof.json").write_text( + json.dumps(proof, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n" + ) + _rich_info( + "Native fallback passed; the old-precedence mutation failed and restoration passed" + ) + return 0 + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError, ET.ParseError) as error: + _rich_error(f"Native Windows proof failed: {error}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/apm_cli/utils/git_env.py b/src/apm_cli/utils/git_env.py index a9e7880971..df673bbc82 100644 --- a/src/apm_cli/utils/git_env.py +++ b/src/apm_cli/utils/git_env.py @@ -985,6 +985,15 @@ def _merge_parent_git_config_snapshot( ) +def _symlink_entry_wins(current: GitConfigEntry | None, candidate: GitConfigEntry) -> bool: + """Decide whether ``candidate`` should replace the retained core.symlinks entry. + + Command-scope intent always wins over any other scope; otherwise the + last entry seen wins, matching Git's own scope-precedence ordering. + """ + return current is None or current.scope != "command" or candidate.scope == "command" + + def _materialize_git_config_snapshot( env: dict[str, str], snapshot: _GitConfigSnapshot, @@ -1005,8 +1014,15 @@ def _materialize_git_config_snapshot( else None ) retained: list[tuple[str, str]] = [] + symlinks: GitConfigEntry | None = None for entry in snapshot.entries: normalized = entry.key.lower() + if normalized == "core.symlinks": + # Parent command entries can precede child file entries after + # merging. Keep command intent above Git init's capability result. + if _symlink_entry_wins(symlinks, entry): + symlinks = entry + continue if entry.scope in {"local", "worktree"} and not _is_scope_sensitive_network_config(entry): continue if normalized == "include.path" or ( @@ -1030,6 +1046,9 @@ def _materialize_git_config_snapshot( continue retained.append((entry.key, entry.value)) + if symlinks is not None: + retained.append(("core.symlinks", symlinks.value)) + if auth_fence is not None: if auth_fence.suppress_helpers: retained.append(("credential.helper", "")) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7bf988fedd..0c7986c76e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -231,6 +231,10 @@ def _is_windows() -> bool: return _platform.system() == "Windows" +def _is_windows_native_standard_user() -> bool: + return _is_windows() and os.environ.get("APM_WINDOWS_NATIVE_STANDARD_USER") == "1" + + def _has_apm_binary() -> bool: return _resolve_apm_binary() is not None @@ -258,6 +262,10 @@ def _has_runtime(name: str) -> bool: "APM_RUN_INTEGRATION_TESTS=1 not set", ), "requires_windows": (_is_windows, "Windows required"), + "requires_windows_native_standard_user": ( + _is_windows_native_standard_user, + "Capability-qualified native Windows standard-user runner required", + ), "requires_apm_binary": ( _has_apm_binary, "apm binary not found on PATH (set APM_BINARY_PATH or build via scripts/build-binary.sh)", diff --git a/tests/integration/test_windows_native_symlink.py b/tests/integration/test_windows_native_symlink.py new file mode 100644 index 0000000000..d13e8098cb --- /dev/null +++ b/tests/integration/test_windows_native_symlink.py @@ -0,0 +1,104 @@ +"""Real denied-capability acceptance on the disposable Windows standard user.""" + +from __future__ import annotations + +import json +import os +import subprocess +from collections.abc import Callable, Iterator +from pathlib import Path + +import pytest + +from apm_cli.utils import git_env +from scripts.windows_native_symlink_probe_entry import MUTATION_FAILURE, probe_native_context +from tests.unit.cache.test_git_symlink_config import _git +from tests.unit.cache.test_git_symlink_config import config_env as config_env +from tests.unit.cache.test_git_symlink_config import symlink_source as symlink_source + +pytestmark = [pytest.mark.component, pytest.mark.requires_windows_native_standard_user] + + +@pytest.fixture +def native_precedence_guard( + monkeypatch: pytest.MonkeyPatch, record_property: Callable[[str, object], None] +) -> Iterator[None]: + """Remove only the local-over-inherited guard for the explicit negative run.""" + mode = os.environ.get("APM_WINDOWS_NATIVE_MUTATION", "") + if mode not in {"", "drop-local-precedence"}: + raise ValueError("Unknown native symlink mutation") + calls = 0 + changes: list[tuple[str, str, str, str, bool, bool]] = [] + original_selection = git_env._symlink_entry_wins + if mode: + + def without_local_precedence( + current: git_env.GitConfigEntry | None, candidate: git_env.GitConfigEntry + ) -> bool: + nonlocal calls + calls += 1 + mutant = current is None or candidate.scope == "command" + correct = original_selection(current, candidate) + if current is not None and mutant != correct: + changes.append( + ( + current.scope, + current.value, + candidate.scope, + candidate.value, + correct, + mutant, + ) + ) + return mutant + + monkeypatch.setattr(git_env, "_symlink_entry_wins", without_local_precedence) + yield + if mode: + assert calls > 0, "The native mutation did not reach the production selection helper" + record_property("mutation_decisions", json.dumps(changes)) + assert ("global", "true", "local", "false", True, False) in changes, ( + "The mutation must actually reject native local false over inherited global true" + ) + + +@pytest.mark.usefixtures("native_precedence_guard") +def test_native_standard_user_symlink_fallback( + tmp_path: Path, + config_env: dict[str, str], + symlink_source: Path, + record_property: Callable[[str, object], None], +) -> None: + """Match native Git with global true, actual local false and no create privilege.""" + context = probe_native_context( + tmp_path / "capability", os.environ["APM_WINDOWS_NATIVE_EXPECTED_SID"] + ) + record_property("native_context", json.dumps(context, sort_keys=True)) + global_path = Path(config_env["GIT_CONFIG_GLOBAL"]) + original_global = global_path.read_bytes() + assert _git(config_env, "config", "--global", "--bool", "core.symlinks") == "true" + native = tmp_path / "native" + target = tmp_path / "apm" + _git(config_env, "clone", "--quiet", "--template=", str(symlink_source), str(native)) + assert _git(config_env, "-C", str(native), "config", "--local", "--bool", "core.symlinks") == ( + "false" + ) + assert not (native / "AGENTS.md").is_symlink() + assert (native / "AGENTS.md").read_bytes() == b"CLAUDE.md" + + try: + git_env.clone_git_worktree(str(symlink_source), target, env=config_env) + except subprocess.CalledProcessError as error: + if "unable to create symlink AGENTS.md" not in (error.stderr or ""): + raise + pytest.fail(f"{MUTATION_FAILURE}; Git exit {error.returncode}") + + assert not (target / "AGENTS.md").is_symlink() + assert (target / "AGENTS.md").read_bytes() == (native / "AGENTS.md").read_bytes() + assert (target / "CLAUDE.md").read_bytes() == (native / "CLAUDE.md").read_bytes() + assert (target / "CLAUDE.md").read_text(encoding="utf-8") == "Package instructions\n" + assert _git(config_env, "-C", str(target), "ls-files", "--stage", "AGENTS.md").startswith( + "120000 " + ) + assert global_path.read_bytes() == original_global + assert _git(config_env, "config", "--global", "--bool", "core.symlinks") == "true" diff --git a/tests/unit/cache/test_git_symlink_config.py b/tests/unit/cache/test_git_symlink_config.py new file mode 100644 index 0000000000..4b6c6cdd3d --- /dev/null +++ b/tests/unit/cache/test_git_symlink_config.py @@ -0,0 +1,281 @@ +"""Real Git regression checks for checkout capability and config precedence.""" + +import os +import subprocess +from pathlib import Path + +import pytest + +from apm_cli.utils import git_env + +pytestmark = [pytest.mark.component, pytest.mark.windows_compat] + + +def _git(env: dict[str, str], *args: str, input: str | None = None) -> str: + """Run a bounded local Git command in the isolated test environment.""" + return subprocess.run( + [git_env.get_git_executable(), *args], + env=env, + input=input, + capture_output=True, + text=True, + check=True, + timeout=30, + ).stdout.strip() + + +@pytest.fixture +def config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: + """Exclude ambient Git state without changing any real user config.""" + for key in tuple(os.environ): + if key.startswith("GIT_"): + monkeypatch.delenv(key) + for scope in ("GLOBAL", "SYSTEM"): + config = tmp_path / f"{scope.lower()}.gitconfig" + config.touch() + monkeypatch.setenv(f"GIT_CONFIG_{scope}", str(config)) + monkeypatch.setenv("GIT_AUTHOR_NAME", "Fixture") + monkeypatch.setenv("GIT_AUTHOR_EMAIL", "fixture@example.test") + monkeypatch.setenv("GIT_COMMITTER_NAME", "Fixture") + monkeypatch.setenv("GIT_COMMITTER_EMAIL", "fixture@example.test") + return dict(os.environ) + + +@pytest.mark.parametrize("inherited_scope", ["system", "global"]) +@pytest.mark.parametrize( + ("inherited", "local_scope", "local", "command_source", "command", "expected"), + [ + ("true", "local", "false", None, None, "false"), + ("false", "local", "true", None, None, "true"), + ("true", "worktree", "false", None, None, "false"), + ("true", "local", "false", "indexed", "true", "true"), + ("true", "local", "false", "parameters", "true", "true"), + ("false", "worktree", "true", "indexed", "false", "false"), + ("true", "local", None, None, None, "true"), + ("false", "local", None, None, None, "false"), + ], +) +def test_network_env_preserves_effective_symlink_setting( + tmp_path: Path, + config_env: dict[str, str], + inherited_scope: str, + inherited: str, + local_scope: str, + local: str | None, + command_source: str | None, + command: str | None, + expected: str, +) -> None: + """A capability result beats inherited settings, but not command intent.""" + config = config_env[f"GIT_CONFIG_{inherited_scope.upper()}"] + _git(config_env, "config", "--file", config, "core.symlinks", inherited) + _git(config_env, "config", "--file", config, "core.compression", "3") + original_config = Path(config).read_bytes() + repo = tmp_path / "repo" + _git(config_env, "init", "--quiet", "--template=", str(repo)) + # Normalize the native init outcome so every precedence case runs on every OS. + _git(config_env, "-C", str(repo), "config", "core.symlinks", "true") + _git(config_env, "-C", str(repo), "config", "--unset", "core.symlinks") + if local_scope == "worktree": + _git(config_env, "-C", str(repo), "config", "extensions.worktreeConfig", "true") + if local is not None: + _git(config_env, "-C", str(repo), "config", f"--{local_scope}", "core.symlinks", local) + if command_source == "indexed": + assert command is not None + config_env.update( + GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="core.symlinks", GIT_CONFIG_VALUE_0=command + ) + elif command_source == "parameters": + config_env["GIT_CONFIG_PARAMETERS"] = f"'core.symlinks={command}'" + query = ("-C", str(repo), "config", "--bool", "--get", "core.symlinks") + assert _git(config_env, *query) == expected + + child = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo) + + assert _git(child, *query) == expected + assert _git(child, "-C", str(repo), "config", "--get", "core.compression") == "3" + assert child["GIT_CONFIG_GLOBAL"] == os.devnull + assert child["GIT_CONFIG_SYSTEM"] == os.devnull + assert Path(config).read_bytes() == original_config + + +@pytest.mark.parametrize( + "sequence", + [ + ("true", "false", "true"), + ("false", "true", "false"), + ("true", "false"), + ("false", "true"), + ("true", "true", "false"), + ], +) +def test_network_env_preserves_last_repeated_command_value( + tmp_path: Path, + config_env: dict[str, str], + sequence: tuple[str, ...], +) -> None: + """Repeated indexed command-scope values must resolve to the last one. + + Guards against collapsing a repeated `GIT_CONFIG_KEY_N`/`VALUE_N` sequence + (e.g. true, false, true) to the first occurrence instead of matching real + Git's last-value-wins semantics for a single-valued setting. + """ + repo = tmp_path / "repo" + _git(config_env, "init", "--quiet", "--template=", str(repo)) + config_env["GIT_CONFIG_COUNT"] = str(len(sequence)) + for index, value in enumerate(sequence): + config_env[f"GIT_CONFIG_KEY_{index}"] = "core.symlinks" + config_env[f"GIT_CONFIG_VALUE_{index}"] = value + query = ("-C", str(repo), "config", "--bool", "--get", "core.symlinks") + expected = sequence[-1] + assert _git(config_env, *query) == expected + + child = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo) + + assert _git(child, *query) == expected + + +def test_network_env_symlink_precedence_preserves_auth_isolation( + tmp_path: Path, + config_env: dict[str, str], +) -> None: + """Symlink precedence resolution must not resurrect scrubbed auth state. + + Regression-traps the shared entry loop in + ``_materialize_git_config_snapshot``: a repeated command-scope + ``core.symlinks`` sequence is interleaved with a command-scope + ``credential.helper`` reset and a stale ``http.extraheader``. A bug that + over-broadly widened the symlinks continue/retention branch could just as + easily let the fenced auth entries pass through unfiltered. + """ + repo = tmp_path / "repo" + _git(config_env, "init", "--quiet", "--template=", str(repo)) + config_env["GIT_CONFIG_COUNT"] = "5" + config_env["GIT_CONFIG_KEY_0"] = "http.extraheader" + config_env["GIT_CONFIG_VALUE_0"] = "Authorization: Basic stale" + config_env["GIT_CONFIG_KEY_1"] = "core.symlinks" + config_env["GIT_CONFIG_VALUE_1"] = "true" + config_env["GIT_CONFIG_KEY_2"] = "credential.helper" + config_env["GIT_CONFIG_VALUE_2"] = "" + config_env["GIT_CONFIG_KEY_3"] = "core.symlinks" + config_env["GIT_CONFIG_VALUE_3"] = "false" + config_env["GIT_CONFIG_KEY_4"] = "credential.helper" + config_env["GIT_CONFIG_VALUE_4"] = "!stale-helper" + + child = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo) + + query = ("-C", str(repo), "config", "--bool", "--get", "core.symlinks") + assert _git(child, *query) == "false" + + assert "GIT_HTTP_EXTRAHEADER" not in child + entries = { + ( + child.get(f"GIT_CONFIG_KEY_{index}", ""), + child.get(f"GIT_CONFIG_VALUE_{index}", ""), + ) + for index in range(int(child.get("GIT_CONFIG_COUNT", "0"))) + } + assert ("credential.helper", "!stale-helper") not in entries + assert all(not value.lower().startswith("authorization:") for _, value in entries) + + +@pytest.mark.parametrize( + ("parent", "child", "expected"), + [ + ("true", None, "true"), + ("false", None, "false"), + ("true", "false", "false"), + ("false", "true", "true"), + ], +) +def test_isolated_child_preserves_parent_command_intent( + tmp_path: Path, + config_env: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + parent: str, + child: str | None, + expected: str, +) -> None: + """An auth-isolated child must not demote the caller's explicit setting.""" + repo = tmp_path / "repo" + _git(config_env, "init", "--quiet", "--template=", str(repo)) + _git(config_env, "-C", str(repo), "config", "core.symlinks", "false") + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.symlinks") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", parent) + config_env.update( + GIT_CONFIG_COUNT="1", + GIT_CONFIG_KEY_0="core.symlinks" if child is not None else "credential.helper", + GIT_CONFIG_VALUE_0=child if child is not None else "", + ) + + result = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo) + + assert _git(result, "-C", str(repo), "config", "--bool", "core.symlinks") == expected + + +@pytest.fixture +def symlink_source(tmp_path: Path, config_env: dict[str, str]) -> Path: + """Commit a real Git symlink without needing OS symlink-create privileges.""" + source = tmp_path / "source" + _git(config_env, "init", "--quiet", "--template=", str(source)) + (source / "CLAUDE.md").write_text("Package instructions\n", encoding="utf-8") + _git(config_env, "-C", str(source), "add", "CLAUDE.md") + oid = _git(config_env, "-C", str(source), "hash-object", "-w", "--stdin", input="CLAUDE.md") + _git( + config_env, + "-C", + str(source), + "update-index", + "--add", + "--cacheinfo", + "120000", + oid, + "AGENTS.md", + ) + _git(config_env, "-C", str(source), "commit", "--quiet", "-m", "Symlink fixture") + _git(config_env, "config", "--global", "core.symlinks", "true") + return source + + +def test_clone_respects_unavailable_symlink_capability( + tmp_path: Path, + config_env: dict[str, str], + symlink_source: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Replay the non-admin Windows init result, then perform a real checkout.""" + real_init = git_env._git_init_run + + def init_without_symlinks(args: list[str], **kwargs: object) -> subprocess.CompletedProcess: + result = real_init(args, **kwargs) + if "init" in args: + _git(config_env, "-C", args[-1], "config", "core.symlinks", "false") + return result + + monkeypatch.setattr(git_env, "_git_init_run", init_without_symlinks) + target = tmp_path / "clone" + + git_env.clone_git_worktree(str(symlink_source), target, env=config_env) + + assert not (target / "AGENTS.md").is_symlink() + assert (target / "AGENTS.md").read_text(encoding="utf-8") == "CLAUDE.md" + assert (target / "CLAUDE.md").read_text(encoding="utf-8") == "Package instructions\n" + assert _git(config_env, "config", "--global", "--get", "core.symlinks") == "true" + + +def test_clone_matches_native_git_symlink_capability( + tmp_path: Path, config_env: dict[str, str], symlink_source: Path +) -> None: + """Exercise actual platform detection without mocking Git or OS capability.""" + native = tmp_path / "native" + target = tmp_path / "apm" + _git(config_env, "clone", "--quiet", "--template=", str(symlink_source), str(native)) + + git_env.clone_git_worktree(str(symlink_source), target, env=config_env) + + assert (target / "AGENTS.md").is_symlink() == (native / "AGENTS.md").is_symlink() + assert (target / "AGENTS.md").read_bytes() == (native / "AGENTS.md").read_bytes() + assert _git(config_env, "-C", str(target), "ls-files", "--stage", "AGENTS.md").startswith( + "120000 " + ) diff --git a/tests/unit/cache/test_git_symlink_precedence.py b/tests/unit/cache/test_git_symlink_precedence.py new file mode 100644 index 0000000000..a782e19d53 --- /dev/null +++ b/tests/unit/cache/test_git_symlink_precedence.py @@ -0,0 +1,32 @@ +"""Truth-table guard for the extracted configuration-selection predicate.""" + +from __future__ import annotations + +import pytest + +from apm_cli.utils.git_env import GitConfigEntry, _symlink_entry_wins + +pytestmark = [pytest.mark.unit, pytest.mark.windows_compat] + + +@pytest.mark.parametrize( + ("current_scope", "candidate_scope", "expected"), + [ + (None, "local", True), + ("global", "local", True), + ("local", "command", True), + ("command", "command", True), + ("command", "local", False), + ("system", "global", True), + ], +) +def test_symlink_entry_wins_truth_table( + current_scope: str | None, candidate_scope: str, expected: bool +) -> None: + current = ( + GitConfigEntry(scope=current_scope, key="core.symlinks", value="true") + if current_scope is not None + else None + ) + candidate = GitConfigEntry(scope=candidate_scope, key="core.symlinks", value="false") + assert _symlink_entry_wins(current, candidate) is expected diff --git a/tests/unit/test_windows_native_symlink_probe.py b/tests/unit/test_windows_native_symlink_probe.py new file mode 100644 index 0000000000..e96d801645 --- /dev/null +++ b/tests/unit/test_windows_native_symlink_probe.py @@ -0,0 +1,301 @@ +"""Simulated probe controls; only the hosted Windows job is native evidence.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock +from xml.sax.saxutils import escape + +import pytest + +import apm_cli +from scripts import windows_native_symlink_probe_entry as probe +from tests.unit.cache.test_git_symlink_config import _git +from tests.unit.cache.test_git_symlink_config import config_env as config_env + +pytestmark = pytest.mark.component + +SID = "S-1-5-21-1-2-3-1001" +NATIVE_CASE = ( + '{}' +) + + +def _report(path: Path, cases: str) -> Path: + path.write_text(f"{cases}", encoding="utf-8") + return path + + +@pytest.mark.parametrize( + ("cases", "mutation", "message"), + [ + ("", False, "exactly once"), + ('', False, "exactly once"), + (NATIVE_CASE.format("") * 2, False, "exactly once"), + (NATIVE_CASE.format(""), False, "without skips"), + (NATIVE_CASE.format(""), False, "without skips"), + (NATIVE_CASE.format("failed"), False, "must pass"), + (NATIVE_CASE.format(""), True, "fail exactly"), + (NATIVE_CASE.format("setup failed"), True, "unrelated"), + ( + NATIVE_CASE.format(f"{escape(probe.MUTATION_FAILURE)}") + + '', + True, + "fail exactly", + ), + ], +) +def test_junit_rejects_nonproof_results( + tmp_path: Path, cases: str, mutation: bool, message: str +) -> None: + with pytest.raises(ValueError, match=message): + probe.check_junit(_report(tmp_path / "result.xml", cases), mutation=mutation) + + +@pytest.mark.parametrize("mutation", [False, True]) +def test_junit_accepts_only_expected_native_outcome(tmp_path: Path, mutation: bool) -> None: + content = f"{escape(probe.MUTATION_FAILURE)}" if mutation else "" + assert ( + probe.check_junit( + _report(tmp_path / "result.xml", NATIVE_CASE.format(content)), mutation=mutation + ) + == 1 + ) + + +@pytest.fixture +def simulated_token(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[list[str]]]: + rows = { + "/user": [["machine\\fixture", SID]], + "/groups": [["Users", "Alias", "S-1-5-32-545", "Enabled"]], + "/priv": [["SeChangeNotifyPrivilege", "Bypass traverse", "Enabled"]], + } + monkeypatch.setattr(probe, "_whoami_rows", rows.__getitem__) + monkeypatch.setattr( + probe, + "ctypes", + SimpleNamespace( + windll=SimpleNamespace(shell32=SimpleNamespace(IsUserAnAdmin=lambda: False)) + ), + ) + + def denied(target: Path, link: Path) -> None: + error = OSError("simulated privilege denial") + error.winerror = 1314 + raise error + + monkeypatch.setattr(probe, "os", SimpleNamespace(name="nt", symlink=denied)) + return rows + + +def test_simulated_denial_exercises_real_scratch_io( + tmp_path: Path, simulated_token: dict[str, list[list[str]]] +) -> None: + context = probe.probe_native_context(tmp_path / "probe", SID) + assert context["sid"] == SID + assert context["ordinary_io"] is True + assert context["symlink_winerror"] == 1314 + assert (tmp_path / "probe" / "ordinary.txt").read_bytes() == b"ordinary read/write succeeds" + + +@pytest.mark.parametrize( + ("option", "rows", "message"), + [ + ("/user", [["machine\\unexpected", "wrong"]], "does not match"), + ("/groups", [], "incomplete"), + ("/priv", [], "incomplete"), + ("/groups", [["broken"]], "incomplete"), + ("/groups", [["Administrators", "Alias", "S-1-5-32-544", "Deny only"]], "administrator"), + ( + "/priv", + [["SeCreateSymbolicLinkPrivilege", "Create links", "Disabled"]], + "symlink privilege", + ), + ], +) +def test_token_rejects_unqualified_identity( + tmp_path: Path, + simulated_token: dict[str, list[list[str]]], + option: str, + rows: list[list[str]], + message: str, +) -> None: + simulated_token[option] = rows + with pytest.raises(RuntimeError, match=message): + probe.probe_native_context(tmp_path / "probe", SID) + assert not (tmp_path / "probe").exists() + + +@pytest.mark.parametrize("winerror", [5, None]) +def test_wrong_native_denial_is_not_accepted( + tmp_path: Path, + simulated_token: dict[str, list[list[str]]], + monkeypatch: pytest.MonkeyPatch, + winerror: int | None, +) -> None: + error = OSError("simulated unrelated error") + error.winerror = winerror + monkeypatch.setattr(probe.os, "symlink", Mock(side_effect=error)) + with pytest.raises(RuntimeError, match="Expected privilege denial"): + probe.probe_native_context(tmp_path / "probe", SID) + + +def test_successful_link_creation_is_not_denied_capability( + tmp_path: Path, + simulated_token: dict[str, list[list[str]]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(probe.os, "symlink", lambda target, link: link.write_bytes(b"simulated")) + with pytest.raises(RuntimeError, match="Symlink creation succeeded"): + probe.probe_native_context(tmp_path / "probe", SID) + assert not (tmp_path / "probe" / "link.txt").exists() + + +@pytest.fixture +def simulated_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Mock]: + root = tmp_path / "checkout" + source = root / "src" / "apm_cli" + source.mkdir(parents=True) + python = root / ".venv" / "Scripts" / "python.exe" + python.parent.mkdir(parents=True) + python.with_name("apm.exe").touch() + monkeypatch.setattr(apm_cli, "__file__", str(source / "__init__.py")) + monkeypatch.setattr(sys, "executable", str(python)) + monkeypatch.setattr(probe, "get_git_executable", lambda: "resolved-git") + command = Mock(side_effect=["a" * 40 + "\n", ""]) + monkeypatch.setattr(subprocess, "check_output", command) + monkeypatch.setenv("APM_BINARY_PATH", "prior-binary") + return root, command + + +def test_provenance_binds_exact_checkout_and_interpreter( + simulated_source: tuple[Path, Mock], +) -> None: + root, command = simulated_source + actual = probe.source_provenance(root, "a" * 40) + assert actual["head"] == "a" * 40 + assert Path(actual["python"]).parent == root / ".venv" / "Scripts" + assert os.environ["APM_BINARY_PATH"] == actual["apm_binary"] + assert command.call_args_list[0].args[0] == [ + "resolved-git", + "-c", + f"safe.directory={root}", + "-C", + str(root), + "rev-parse", + "HEAD", + ] + assert command.call_args_list[1].args[0][-3:] == [ + "status", + "--porcelain", + "--untracked-files=no", + ] + + +@pytest.mark.parametrize("failure", ["head", "dirty", "source", "python", "binary"]) +def test_provenance_rejects_wrong_source( + simulated_source: tuple[Path, Mock], monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + root, command = simulated_source + if failure == "head": + command.side_effect = ["b" * 40, ""] + elif failure == "dirty": + command.side_effect = ["a" * 40, " M src/apm_cli/utils/git_env.py\n"] + elif failure == "source": + monkeypatch.setattr(apm_cli, "__file__", str(root / "elsewhere" / "__init__.py")) + elif failure == "python": + monkeypatch.setattr(sys, "executable", str(root / "global" / "python.exe")) + else: + (root / ".venv" / "Scripts" / "apm.exe").unlink() + with pytest.raises(RuntimeError): + probe.source_provenance(root, "a" * 40) + assert os.environ["APM_BINARY_PATH"] == "prior-binary" + + +@pytest.mark.parametrize("pin_checkout_lf", [False, True]) +def test_provenance_survives_config_isolation_only_with_bound_checkout_eol( + tmp_path: Path, + config_env: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + pin_checkout_lf: bool, +) -> None: + """Real Git reproduces CRLF dirtiness when the parent config is removed.""" + seed = tmp_path / "seed" + _git(config_env, "init", "--quiet", "--template=", str(seed)) + source = seed / "src" / "apm_cli" + source.mkdir(parents=True) + (source / "__init__.py").write_bytes(b"fixture = True\n") + _git(config_env, "-C", str(seed), "add", "src") + _git(config_env, "-C", str(seed), "commit", "--quiet", "-m", "Fixture") + system_config = Path(config_env["GIT_CONFIG_SYSTEM"]) + system_config.write_text("[core]\n autocrlf = true\n", encoding="utf-8") + clone_env = dict(config_env) + if pin_checkout_lf: + clone_env.update( + GIT_CONFIG_COUNT="1", + GIT_CONFIG_KEY_0="core.autocrlf", + GIT_CONFIG_VALUE_0="false", + ) + root = tmp_path / "checkout" + _git(clone_env, "clone", "--quiet", "--template=", str(seed), str(root)) + head = _git(config_env, "-C", str(root), "rev-parse", "HEAD") + checked_out = root / "src" / "apm_cli" / "__init__.py" + assert checked_out.read_bytes() == ( + b"fixture = True\n" if pin_checkout_lf else b"fixture = True\r\n" + ) + system_config.write_bytes(b"") + stamp = checked_out.stat().st_mtime_ns + 2_000_000_000 + os.utime(checked_out, ns=(stamp, stamp)) + python = root / ".venv" / "Scripts" / "python.exe" + python.parent.mkdir(parents=True) + python.with_name("apm.exe").touch() + monkeypatch.setattr(sys, "executable", str(python)) + monkeypatch.setattr(apm_cli, "__file__", str(checked_out)) + monkeypatch.setenv("APM_BINARY_PATH", "prior") + if pin_checkout_lf: + assert probe.source_provenance(root, head)["head"] == head + else: + with pytest.raises(RuntimeError, match="tracked acceptance source is dirty"): + probe.source_provenance(root, head) + + +@pytest.mark.parametrize("phase", ["baseline", "mutation", "restored"]) +def test_phase_uses_pinned_python_and_inspects_actual_result( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, phase: str +) -> None: + mutation = phase == "mutation" + body = f"{escape(probe.MUTATION_FAILURE)}" if mutation else "" + cases = NATIVE_CASE.format(body) + if phase == "baseline": + cases += '' + _report(tmp_path / f"{phase}.xml", cases) + command = Mock(return_value=subprocess.CompletedProcess([], int(mutation))) + monkeypatch.setattr(subprocess, "run", command) + monkeypatch.setenv("APM_WINDOWS_NATIVE_MUTATION", "stale") + actual = probe.run_phase(tmp_path, tmp_path, phase) + args = command.call_args.args[0] + assert args[:4] == [sys.executable, "-B", "-m", "pytest"] + assert args[-1] == probe.NATIVE_NODE + assert ("tests/unit/cache/test_git_symlink_config.py" in args) is (phase == "baseline") + assert command.call_args.kwargs["timeout"] == 180 + assert command.call_args.kwargs["env"].get("APM_WINDOWS_NATIVE_MUTATION") == ( + "drop-local-precedence" if mutation else None + ) + assert actual["exit_code"] == int(mutation) + assert actual["cases"] == (2 if phase == "baseline" else 1) + + +@pytest.mark.parametrize("exit_code", [1, 2, 5]) +def test_phase_rejects_nonpassing_exit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, exit_code: int +) -> None: + monkeypatch.setattr( + subprocess, "run", Mock(return_value=subprocess.CompletedProcess([], exit_code)) + ) + with pytest.raises(RuntimeError, match="expected pytest exit 0"): + probe.run_phase(tmp_path, tmp_path, "baseline") diff --git a/tests/unit/test_windows_native_symlink_workflow.py b/tests/unit/test_windows_native_symlink_workflow.py new file mode 100644 index 0000000000..8178679466 --- /dev/null +++ b/tests/unit/test_windows_native_symlink_workflow.py @@ -0,0 +1,63 @@ +"""Keep the privileged fixture isolated, exact-head, bounded and fail-closed.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.workflow_contracts import load_workflow, workflow_job, workflow_step, workflow_step_index + +pytestmark = pytest.mark.component +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +JOB = "windows-native-standard-user-symlink-gate" +SCRIPT = "./scripts/windows_native_standard_user_symlink_gate.ps1" +EXACT_HEAD = "${{ github.event.pull_request.head.sha || github.sha }}" + + +def test_native_job_is_unconditional_bounded_and_read_only() -> None: + job = workflow_job(load_workflow(WORKFLOW), JOB) + assert job["runs-on"] == "windows-latest" + assert job["permissions"] == {"contents": "read"} + assert 0 < job["timeout-minutes"] <= 15 + assert "if" not in job + assert not job.get("continue-on-error", False) + checkout = job["steps"][0] + assert checkout["uses"].split("@")[0] == "actions/checkout" + assert checkout["env"] == { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.autocrlf", + "GIT_CONFIG_VALUE_0": "false", + } + assert checkout["with"] == {"ref": EXACT_HEAD, "persist-credentials": False} + assert workflow_step(job, "Install dependencies")["run"] == "uv sync --frozen --extra dev" + + +def test_native_cleanup_and_evidence_survive_subject_failure() -> None: + job = workflow_job(load_workflow(WORKFLOW), JOB) + subject = workflow_step(job, "Prove native standard-user fallback") + assert subject["env"] == {"APM_NATIVE_EXPECTED_HEAD": EXACT_HEAD} + assert subject["run"] == f"{SCRIPT} -ExpectedHead $env:APM_NATIVE_EXPECTED_HEAD" + assert subject["shell"] == "pwsh" + assert 0 < subject["timeout-minutes"] <= 10 + assert not subject.get("continue-on-error", False) + cleanup = workflow_step(job, "Restore owned native fixture") + assert cleanup["if"] == "always()" + assert cleanup["run"] == f"{SCRIPT} -CleanupOnly" + assert cleanup["shell"] == "pwsh" + assert 0 < cleanup["timeout-minutes"] <= 2 + assert not cleanup.get("continue-on-error", False) + upload = workflow_step(job, "Retain native proof and restoration evidence") + assert upload["if"] == "always()" + assert upload["uses"].split("@")[0] == "actions/upload-artifact" + assert upload["with"]["path"] == "${{ runner.temp }}/apm-native-symlink-evidence/" + assert upload["with"]["if-no-files-found"] == "error" + assert workflow_step_index(job, subject["name"]) < workflow_step_index(job, cleanup["name"]) + assert workflow_step_index(job, cleanup["name"]) < workflow_step_index(job, upload["name"]) + + +def test_native_script_is_in_lint_format_and_duplication_checks() -> None: + lint = workflow_job(load_workflow(WORKFLOW), "lint") + for name in ("Ruff lint", "Ruff format check", "Code duplication guardrail (pylint R0801)"): + assert "scripts/windows_native_symlink_probe_entry.py" in workflow_step(lint, name)["run"]