diff --git a/.build/Pester.ps1 b/.build/Pester.ps1 index 70646a7781..34f7eb2a35 100644 --- a/.build/Pester.ps1 +++ b/.build/Pester.ps1 @@ -40,9 +40,15 @@ begin { } } + # Only `.github/examples` is excluded. Those files are documentation samples + # demonstrating test style, not tests of shipping code, and they were the + # sole reason this exclusion was added. Real tests elsewhere under `.github` + # -- notably the agent-skill helper tests at + # `.github/skills//Tests/*.Tests.ps1` -- MUST run, because they are + # the drift guards that pin the skills to the product code they parse. $root = Get-Item "$PSScriptRoot\.." $scripts = @(Get-ChildItem -Recurse $root | - Where-Object { $_.Name -like "*.Tests.ps1" -and $_.FullName -notmatch "\.github" }) + Where-Object { $_.Name -like "*.Tests.ps1" -and $_.FullName -notmatch "\.github[\\/]examples[\\/]" }) # Categorize test files by priority using comment tag: # PesterPriority: High $highPriorityQueue = New-Object 'System.Collections.Generic.Queue[object]' diff --git a/.build/Tests/Pester.Tests.ps1 b/.build/Tests/Pester.Tests.ps1 new file mode 100644 index 0000000000..ad5683c93f --- /dev/null +++ b/.build/Tests/Pester.Tests.ps1 @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Pester runner branch selection' { + BeforeAll { + $Script:runnerPath = Join-Path -Path $PSScriptRoot -ChildPath '..\Pester.ps1' + + if (-not ('PesterRunnerTestJob' -as [type])) { + Add-Type -TypeDefinition @' +using System.Management.Automation; +public sealed class PesterRunnerTestJob : Job { + public PesterRunnerTestJob(string name) : base("fixture", name) { + SetJobState(JobState.Running); + SetJobState(JobState.Completed); + } + public override string Location { get { return ""; } } + public override string StatusMessage { get { return ""; } } + public override bool HasMoreData { get { return false; } } + public override void StopJob() { } +} +'@ + } + + function git { + throw 'Unexpected git invocation without a mock.' + } + + function Invoke-TestRunner { + & $Script:runnerPath -NoProgress -Branch main *> $null + } + } + + BeforeEach { + $Script:runnerState = @{ + ChangedFile = '.github/skills/analyze-debug-files/SKILL.md' + SelectedPaths = [System.Collections.Generic.List[string]]::new() + } + $state = $Script:runnerState + Mock -CommandName git -MockWith ({ + switch ($args[0]) { + 'log' { return '1111111111111111111111111111111111111111 Wed, 28 Oct 2026 07:46:41 -0500' } + 'diff-tree' { return $state.ChangedFile } + 'status' { return } + } + throw "Unexpected git arguments: $($args -join ' ')" + }.GetNewClosure()) + Mock -CommandName Start-Job -MockWith ({ + param($ArgumentList, $Name) + $state.SelectedPaths.Add([string]@($ArgumentList)[0]) + return [PesterRunnerTestJob]::new($Name) + }.GetNewClosure()) + Mock -CommandName Receive-Job -MockWith { + return [PSCustomObject]@{ + Result = 'Passed' + Duration = [TimeSpan]::FromMilliseconds(1) + } + } + Mock -CommandName Remove-Job -MockWith {} + } + + It 'skips documentation-only changes for , matching main' -ForEach @( + @{ ChangedFile = '.github/skills/analyze-debug-files/SKILL.md' } + @{ ChangedFile = '.github/skill-lib/README.md' } + @{ ChangedFile = 'README.md' } + ) { + $Script:runnerState.ChangedFile = $ChangedFile + Invoke-TestRunner + + $Script:runnerState.SelectedPaths.Count | Should -Be 0 + } + + It 'retains all test areas when changes' -ForEach @( + @{ ChangedFile = '.build/Pester.ps1' } + @{ ChangedFile = '.github/skills/analyze-debug-files/Get-DebugFileMetadata.ps1' } + ) { + $Script:runnerState.ChangedFile = $ChangedFile + Invoke-TestRunner + + @($Script:runnerState.SelectedPaths | Where-Object { + $_ -notmatch '[\\/]\.github[\\/]' + }).Count | Should -BeGreaterThan 0 + @($Script:runnerState.SelectedPaths | Where-Object { + (Split-Path -Path $_ -Leaf) -eq 'SkillDocContract.Tests.ps1' + }).Count | Should -Be 1 + @($Script:runnerState.SelectedPaths | Where-Object { + $_ -match '[\\/]\.github[\\/]examples[\\/]' + }).Count | Should -Be 0 + } + + It 'retains all test areas without a branch filter even for documentation-only changes' { + & $Script:runnerPath -NoProgress *> $null + + @($Script:runnerState.SelectedPaths | Where-Object { + $_ -notmatch '[\\/]\.github[\\/]' + }).Count | Should -BeGreaterThan 0 + @($Script:runnerState.SelectedPaths | Where-Object { + (Split-Path -Path $_ -Leaf) -eq 'SkillDocContract.Tests.ps1' + }).Count | Should -Be 1 + @($Script:runnerState.SelectedPaths | Where-Object { + $_ -match '[\\/]\.github[\\/]examples[\\/]' + }).Count | Should -Be 0 + } +} diff --git a/.build/cspell-words.txt b/.build/cspell-words.txt index 4c32b37913..b2919dd574 100644 --- a/.build/cspell-words.txt +++ b/.build/cspell-words.txt @@ -97,9 +97,12 @@ mepfdns mepfs meso mesos +metacharacters mfcmapi Mgmt microsoftonline +misattributed +misrouting mitigations msdcs MSDTC @@ -163,6 +166,7 @@ subfolders syncall tcpip TDSDSA +triaging Truncater UCMA unconfigured @@ -181,6 +185,7 @@ Webex Weve wevtutil windir +worktree xtap Xlsb Xtap diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aff35c22d8..2350491a60 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -32,6 +32,20 @@ Specialized agents are available in `.github/agents/` for specific tasks: Skills are available in `.github/skills/` and are automatically loaded when relevant: - **dependency-analysis** — Maps cascading impact of changes using build system XML. Auto-loads when working with shared functions or assessing change impact. Can also be invoked explicitly: `"Use the /dependency-analysis skill to analyze impact of changing FunctionName"` +- **analyze-debug-files** — Analyzes a directory of CSS-Exchange debug logs, pins the source to a release-tag baseline, and produces a root-cause analysis of unhandled exceptions. Use: `"Use the /analyze-debug-files skill on C:\logs"` +- **find-release-tag-for-script-version** — Given a script name and version (e.g. `HealthChecker 26.10.02.1907`), finds the earliest GitHub release whose `ScriptVersions.csv` shipped that build. +- **find-related-github-issues** — Given an exception signature, searches Issues and PRs and classifies each hit as a possible duplicate or merely similar. +- **trace-code-introduction** — Given a file path, line range, and pinned SHA, identifies the commit and PR that introduced that code, with a conservative regression verdict. + +Shared filesystem-safety helpers used by these skills live in `.github/skill-lib/`. See its README — skills that depend on it are repository-scoped and are not independently distributable. + +**Skill authoring rules:** + +- Keep `SKILL.md` under 500 lines and roughly 5,000 tokens, per the [Agent Skills specification](https://github.com/agentskills/agentskills). Move detail into a `reference/` subfolder that the skill loads on demand. +- Put executable logic in a real `.ps1` beside `SKILL.md`, never in a fenced code block. Code in markdown gets **zero** PSScriptAnalyzer, formatter, and Pester coverage. +- Supported front matter keys are `name`, `description`, `license`, and `allowed-tools`. Any other key is silently ignored. +- Do **not** declare `allowed-tools: shell` on a skill that consumes untrusted input (debug logs, GitHub issue/PR content, downloaded release assets). Keeping it omitted preserves the human confirmation step against prompt injection. +- `.build/Pester.ps1` discovers skill tests at `.github/skills//Tests/*.Tests.ps1` — put them beside the skill they test, not in a product folder. Contract tests for `.github/skill-lib/` live at `.github/skill-lib/Tests/` because those helpers are shared across skills. ## Personal Overrides @@ -117,7 +131,7 @@ For formatting all changed files on a branch: - Uses `cspell` (Node.js-based tool) - Dictionary is in `.build/cspell-words.txt` - Most spelling issues are caught by using proper camelCase or PascalCase variable names -- Note: `.github/` is excluded from spell checking via `.build/cspell.json` +- Note: `.build/cspell.json` lists `.github` in `ignorePaths`, but that pattern does **not** match descendants — `.github/**` files ARE spell-checked in practice. Add new words to `.build/cspell-words.txt` as normal. ### Testing (Pester) @@ -125,7 +139,7 @@ For formatting all changed files on a branch: .build/Pester.ps1 ``` -- Runs all Pester tests (`*.Tests.ps1`) found recursively across the repository (excludes `.github/`) +- Runs all Pester tests (`*.Tests.ps1`) found recursively across the repository, including skill tests (excludes only `.github/examples/`) - Runs tests in parallel (auto-scales to CPU cores) - To test only changed files: `.build/Pester.ps1 -Branch main` - Tests use `BeforeAll` to dot-source scripts and set up the environment @@ -222,6 +236,8 @@ Tests follow a **colocated pattern**: `*.Tests.ps1` files are placed in a `Tests - `Setup/Tests/*.Tests.ps1` - Tests for Setup scripts - `Diagnostics/HealthChecker/Tests/*.Tests.ps1` - Tests for HealthChecker - `Admin/MonitorExchangeAuthCertificate/DataCollection/Tests/*.Tests.ps1` - Tests colocated near the script +- `.github/skills//Tests/*.Tests.ps1` - Tests for agent skill helper scripts +- `.github/skill-lib/Tests/*.Tests.ps1` - Contract tests for the shared skill helpers (they are consumed by more than one skill, so they are not owned by any single skill's folder) Basic test structure: @@ -250,4 +266,3 @@ Key conventions: - Store mock data in XML files in a `Tests/Data/` subfolder: `Import-Clixml $Script:parentPath\Tests\Data\MockData.xml` - For complex multi-script testing (e.g., HealthChecker), create helper functions in `NotPublished.ps1` files (e.g., `HealthCheckerTests.ImportCode.NotPublished.ps1`) - Load functions dynamically with `Get-PesterScriptContent` and `Invoke-Expression` when testing individual functions from large scripts - diff --git a/.github/skill-lib/README.md b/.github/skill-lib/README.md new file mode 100644 index 0000000000..0ee65372a0 --- /dev/null +++ b/.github/skill-lib/README.md @@ -0,0 +1,89 @@ +# skill-lib + +Shared filesystem-safety helpers dot-sourced by skills under `.github/skills/`. + +This directory sits **beside** `.github/skills/`, not inside it, so the skill +loader does not attempt to discover a `SKILL.md` here. Each helper is a +single-purpose `.ps1` file with a documented contract at the top; skills +dot-source only the helpers they need. + +## When to add a helper here + +Extract a helper into this directory only when it is: + +- **Byte-for-byte reusable** across two or more skills. If two callers need + different behavior, keep the helpers inline in each skill — divergent + copies with the same name are worse than duplication because they silently + break the "same call, same behavior" contract downstream. +- **Purely defensive filesystem plumbing** (path validation, reparse-point + detection, DOS-device probes, handle equality checks). Domain logic, + reporting helpers, and one-of-a-kind orchestration stay in the calling + skill. + +## Consuming a helper + +Skills dot-source with a `$PSScriptRoot`-relative path that walks up out of +`.github/skills//` and back down into `.github/skill-lib/`: + +```powershell +. $PSScriptRoot\..\..\skill-lib\Resolve-SkillLocalPath.ps1 +``` + +The `analyze-debug-files` helpers — `Get-DebugFileMetadata.ps1`, +`Assert-BaselineTrusted.ps1`, `Get-BaselineDependencyGraph.ps1`, and +`Write-DebugAnalysisReport.ps1` — all use this same `$PSScriptRoot`-relative +form, because each is a real `.ps1` file where the variable is well-defined. + +`Resolve-SkillLocalPath` owns the shared path policy and loads the two defensive +primitives itself. It returns a validated absolute path, or `$null` on refusal. +Callers must use that returned path rather than normalize the input again. +Relative paths resolve against PowerShell's current location. `-PathType Leaf` +requires an existing file; the default requires a directory. `-AllowMissing` +performs the same locality checks without requiring existence or an item type, +for paths that the caller will create. + +Each helper's `.DESCRIPTION` carries a `Consumed by:` list. That list is not +decorative — `Tests/SkillLibContract.Tests.ps1` derives the real consumer set +from both the skills and this library, and fails when either the list or the "Skills that depend +on this directory" section below falls out of sync. + +## Repository-only: these skills are not independently distributable + +Because the helpers live **outside** each skill's own directory, the +`.github/skills//` folders are **not** self-contained. The Agent +Skills loader discovers and bundles only the files inside a skill's directory, +so copying one of these skill folders on its own — or installing it with +`gh skill install` — produces a skill that fails at dot-source time. + +Skills that depend on this directory: + +- `.github/skills/analyze-debug-files/` +- `.github/skills/trace-code-introduction/` + +These are **repository-scoped skills**, intended to run from a +`microsoft/CSS-Exchange` clone. That is a deliberate trade: a single canonical +copy of each safety helper is worth more than independent distributability, +because divergent copies of a path-validation routine fail silently and +dangerously. If a skill here ever needs to be distributed standalone, vendor the +helpers into its own directory at that point rather than loosening this rule. + +`find-release-tag-for-script-version` intentionally carries equivalent inline +filesystem checks to remain self-contained. It is not a consumer of this +directory. When changing the canonical policy or its primitives, review that +standalone copy for the same edge case; do not silently make it depend on this +library. + +## Add-Type namespace convention + +Helpers that use `Add-Type` for P/Invoke declare types under the +`SkillLib.*` namespace and guard the declaration with an `-as [type]` +check so re-sourcing is a no-op: + +```powershell +if (-not ('SkillLib.DosDeviceHelper' -as [type])) { + Add-Type -Namespace 'SkillLib' -Name 'DosDeviceHelper' -MemberDefinition ... +} +``` + +A single shared namespace means the P/Invoke types load once per PowerShell +session even when multiple skills consume the same helper. diff --git a/.github/skill-lib/Resolve-SkillLocalPath.ps1 b/.github/skill-lib/Resolve-SkillLocalPath.ps1 new file mode 100644 index 0000000000..c4ff5d70b6 --- /dev/null +++ b/.github/skill-lib/Resolve-SkillLocalPath.ps1 @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Resolve a safe local filesystem path, or return null. + +.DESCRIPTION + Rejects UNC, provider-qualified, device-namespace, drive-relative, + network-backed, SUBST-aliased, and reparse-point-backed paths. + Relative paths resolve against PowerShell's current location, not the + .NET process directory. Callers must use the returned absolute path. + + Provider and drive metadata are checked before filesystem inspection. + The unresolved provider-path API normalizes paths without probing + their targets; the root-to-leaf reparse walk precedes existence checks. + + Consumed by: + - .github/skills/analyze-debug-files/Get-DebugFileMetadata.ps1 + - .github/skills/analyze-debug-files/Assert-BaselineTrusted.ps1 + - .github/skills/analyze-debug-files/Get-BaselineDependencyGraph.ps1 + - .github/skills/analyze-debug-files/Write-DebugAnalysisReport.ps1 + - .github/skills/trace-code-introduction/Trace-CodeIntroduction.ps1 + +.PARAMETER Path + Caller-supplied path. Invalid or inaccessible paths return null. + +.PARAMETER PathType + Required existing item type: Container (default) or Leaf. + +.PARAMETER AllowMissing + Validate locality without requiring existence or enforcing PathType. + Used for cache paths that may not have been created yet. + +.OUTPUTS + The validated absolute filesystem path, or null on refusal. +#> +. $PSScriptRoot\Test-IsLocalDosDeviceTarget.ps1 +. $PSScriptRoot\Test-PathHasReparsePointRootToLeaf.ps1 + +function Resolve-SkillLocalPath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Path, + + [ValidateSet('Container', 'Leaf')] + [string]$PathType = 'Container', + + [switch]$AllowMissing + ) + + if ([string]::IsNullOrWhiteSpace($Path) -or $Path.Contains("`0") -or $Path.Contains('::')) { + return $null + } + if ($Path -match '^[\\/]{2}' -or $Path -match '^\\\?\?\\') { + return $null + } + if ($Path -match '^([A-Za-z][A-Za-z0-9_+.-]*):' -and $Matches[1].Length -gt 1) { + return $null + } + if ($Path -match '^[A-Za-z]:(?![\\/])') { + return $null + } + + try { + $isWin = [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT + if ($isWin) { + if ($Path -match '^([A-Za-z]):[\\/]') { + $inputDrive = $Matches[1] + } else { + $location = Get-Location + if ($location.Provider.Name -ne 'FileSystem' -or + $location.ProviderPath -notmatch '^([A-Za-z]):[\\/]') { + return $null + } + $inputDrive = $Matches[1] + if ($location.Drive.Root -notmatch '^[A-Za-z]:[\\/]?$') { + return $null + } + } + $inputDriveInfo = Get-PSDrive -Name $inputDrive -ErrorAction Stop + if ($inputDriveInfo.Provider.Name -ne 'FileSystem' -or + $inputDriveInfo.Root.Replace('/', '\') -ine "${inputDrive}:\") { + return $null + } + } + + $providerInfo = $null + $driveInfo = $null + $providerPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $Path, [ref]$providerInfo, [ref]$driveInfo) + if ($null -eq $providerInfo -or $providerInfo.Name -ne 'FileSystem') { + return $null + } + $fullPath = [System.IO.Path]::GetFullPath($providerPath) + if ($isWin) { + if ($fullPath -notmatch '^[A-Za-z]:[\\/]' -or + $null -eq $driveInfo -or $driveInfo.Root -notmatch '^[A-Za-z]:[\\/]?$') { + return $null + } + $driveLetter = $fullPath.Substring(0, 1) + $resolvedDriveInfo = Get-PSDrive -Name $driveLetter -ErrorAction Stop + if ($resolvedDriveInfo.Provider.Name -ne 'FileSystem' -or + $resolvedDriveInfo.Root.Replace('/', '\') -ine "${driveLetter}:\") { + return $null + } + $volume = [System.IO.DriveInfo]::new("${driveLetter}:\") + $allowed = @([System.IO.DriveType]::Fixed, [System.IO.DriveType]::Removable, [System.IO.DriveType]::Ram) + if ($allowed -notcontains $volume.DriveType -or + -not (Test-IsLocalDosDeviceTarget -DriveLetter "${driveLetter}:")) { + return $null + } + } elseif ($fullPath -notmatch '^/') { + return $null + } + if (Test-PathHasReparsePointRootToLeaf -Path $fullPath) { + return $null + } + if (-not $AllowMissing -and -not (Test-Path -LiteralPath $fullPath -PathType $PathType -ErrorAction Stop)) { + return $null + } + return $fullPath + } catch { + return $null + } +} diff --git a/.github/skill-lib/Test-IsLocalDosDeviceTarget.ps1 b/.github/skill-lib/Test-IsLocalDosDeviceTarget.ps1 new file mode 100644 index 0000000000..5f560255c1 --- /dev/null +++ b/.github/skill-lib/Test-IsLocalDosDeviceTarget.ps1 @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Returns $true when a bare drive letter maps to a real local volume, + $false for SUBST/DefineDosDevice-created drives, raw DOS device aliases, + or drives that fail QueryDosDevice. + +.DESCRIPTION + QueryDosDevice check: reject SUBST/DefineDosDevice-created drives + (their target is `\??\`) and raw DOS device aliases + (`\Device\\`). A real local volume maps to a bare + `\Device\` target with no trailing path component. + + `[System.IO.DriveInfo]` alone is not enough — a SUBST'd drive reports + DriveType.Fixed but redirects to arbitrary targets (including UNC or + reparse-point paths), so callers must combine DriveInfo with this + check to close the SUBST bypass. + + Consumed by: + - .github/skill-lib/Resolve-SkillLocalPath.ps1 + +.PARAMETER DriveLetter + A single drive-letter token: bare (`C`) or colon-suffixed (`C:`). Any + other shape — multi-character names (`NUL`, `CON`, `LPT1`), embedded + path separators, empty strings, or non-ASCII characters — is rejected + without calling `QueryDosDevice`. This defends against caller mistakes + that would otherwise let multi-character DOS aliases (which resolve to + a bare `\Device\` target and match the local-volume shape check) + slip through as "local drives." + +.OUTPUTS + [bool] — $true when the drive maps to `\Device\` with no + trailing path segment; otherwise $false. + +.EXAMPLE + PS> Test-IsLocalDosDeviceTarget -DriveLetter 'C:' + True + +.EXAMPLE + PS> subst X: C:\Users + PS> Test-IsLocalDosDeviceTarget -DriveLetter 'X:' + False +#> +function Test-IsLocalDosDeviceTarget { + param([Parameter(Mandatory)][string]$DriveLetter) + # STRICT shape check first — reject anything that isn't a single ASCII + # letter, optionally with a trailing colon. Multi-character DOS device + # names like `NUL`, `CON`, `LPT1`, `COM1`, `PhysicalDrive0` also + # resolve to a bare `\Device\` target and would otherwise match + # the local-volume regex below. + if ($DriveLetter -notmatch '^[A-Za-z]:?$') { return $false } + # QueryDosDevice requires the trailing colon; accept the bare drive + # letter form to match the parameter contract and normalize here so + # callers don't have to remember the format. + $name = if ($DriveLetter.Length -eq 1) { "${DriveLetter}:" } else { $DriveLetter } + if (-not ('SkillLib.DosDeviceHelper' -as [type])) { + Add-Type -Namespace 'SkillLib' -Name 'DosDeviceHelper' -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode, SetLastError=true)] +public static extern uint QueryDosDevice(string lpDeviceName, System.Text.StringBuilder lpTargetPath, uint maxChars); +'@ -ErrorAction Stop + } + $sb = New-Object System.Text.StringBuilder 1024 + $len = [SkillLib.DosDeviceHelper]::QueryDosDevice($name, $sb, 1024) + if ($len -eq 0) { return $false } + $target = $sb.ToString() + return ($target -match '\A\\Device\\[^\\]+\z') +} diff --git a/.github/skill-lib/Test-PathHasReparsePointRootToLeaf.ps1 b/.github/skill-lib/Test-PathHasReparsePointRootToLeaf.ps1 new file mode 100644 index 0000000000..8749260d45 --- /dev/null +++ b/.github/skill-lib/Test-PathHasReparsePointRootToLeaf.ps1 @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Returns $true if any component from the volume root down to the target + is a reparse point (junction/symlink); otherwise $false. Segments that + do not yet exist are treated as safe. + +.DESCRIPTION + Walks root → leaf. Returns $true as soon as any ancestor is a reparse + point, WITHOUT ever calling filesystem cmdlets on a descendant of a + reparse ancestor. Uses attribute-only reads (no follow) via + `[System.IO.File]::GetAttributes` so a directory symlink pointing at + a UNC share is not opened as part of the check. + + Not-yet-existing tail segments return $false — the caller may create + a file into an existing safe directory. Any error inspecting an + ancestor (access denied, broken link, etc.) is treated as UNSAFE and + returns $true rather than assuming absence of a reparse point. + + Consumed by: + - .github/skill-lib/Resolve-SkillLocalPath.ps1 + +.PARAMETER Path + An absolute filesystem path. Callers should pass a lexically local, + resolved path — validating the path shape is out of scope for this + helper. + +.OUTPUTS + [bool] — $true if any ancestor component is a reparse point or a + filesystem error occurs; $false when the entire chain is a plain + directory tree. +#> +function Test-PathHasReparsePointRootToLeaf { + param([Parameter(Mandatory)][string]$Path) + try { + $normalized = [System.IO.Path]::GetFullPath($Path) + } catch { + return $true + } + $parts = New-Object System.Collections.Generic.List[string] + $cur = $normalized + while (-not [string]::IsNullOrEmpty($cur)) { + $parts.Insert(0, $cur) + $parent = Split-Path -Parent $cur + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $cur) { break } + $cur = $parent + } + foreach ($p in $parts) { + try { + $attrs = [System.IO.File]::GetAttributes($p) + if (($attrs -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + return $true + } + } catch [System.IO.FileNotFoundException] { + # Not-yet-existing tail segments are OK — the workflow may + # create the report file into an existing directory. + continue + } catch [System.IO.DirectoryNotFoundException] { + continue + } catch { + # Any other error while inspecting an ancestor is a hard fail. + return $true + } + } + return $false +} diff --git a/.github/skill-lib/Tests/Resolve-SkillLocalPath.Tests.ps1 b/.github/skill-lib/Tests/Resolve-SkillLocalPath.Tests.ps1 new file mode 100644 index 0000000000..4951cb69fe --- /dev/null +++ b/.github/skill-lib/Tests/Resolve-SkillLocalPath.Tests.ps1 @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Resolve-SkillLocalPath' { + BeforeAll { + $Script:helperPath = Join-Path -Path $PSScriptRoot -ChildPath '..\Resolve-SkillLocalPath.ps1' + . $Script:helperPath + $Script:fixtureRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('SkillPathTests-' + [guid]::NewGuid().ToString('N')) + New-Item -Path $Script:fixtureRoot -ItemType Directory | Out-Null + $Script:filePath = Join-Path -Path $Script:fixtureRoot -ChildPath 'fixture.txt' + [System.IO.File]::WriteAllText($Script:filePath, 'Controlled fixture') + } + + AfterAll { + Remove-Item -LiteralPath $Script:fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'resolves a relative path against PowerShell rather than the process directory' { + $processDirectory = [System.Environment]::CurrentDirectory + Push-Location -LiteralPath $Script:fixtureRoot + try { + [System.Environment]::CurrentDirectory = [System.IO.Path]::GetPathRoot($Script:fixtureRoot) + Resolve-SkillLocalPath -Path '.' | Should -BeExactly $Script:fixtureRoot + } finally { + [System.Environment]::CurrentDirectory = $processDirectory + Pop-Location + } + } + + It 'requires existence unless missing paths are explicitly allowed' { + $missing = Join-Path -Path $Script:fixtureRoot -ChildPath 'missing\cache.xml' + Resolve-SkillLocalPath -Path $missing | Should -BeNullOrEmpty + Resolve-SkillLocalPath -Path $missing -AllowMissing | Should -BeExactly $missing + } + + It 'distinguishes existing files from directories' { + Resolve-SkillLocalPath -Path $Script:filePath | Should -BeNullOrEmpty + Resolve-SkillLocalPath -Path $Script:filePath -PathType Leaf | Should -BeExactly $Script:filePath + Resolve-SkillLocalPath -Path $Script:fixtureRoot -PathType Leaf | Should -BeNullOrEmpty + } + + It 'refuses