scripts/internal/continuous-co-review/path-identity.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # The ONE path-identity primitive. Four independent OS-family shortcuts previously decided case # semantics per call site, and every one of them was wrong on a case-insensitive macOS volume: # case sensitivity is a property of the VOLUME, not the OS family. Route new comparisons through # here rather than adding a fifth local rule. # Ordinal on purpose. A plain `@{}` hashtable folds its string keys, which is the very defect this # file exists to prevent (DRIFT-198-I009-024 / -030) - the primitive must not cache path answers in a # map that cannot tell two case-distinct roots apart. $script:ContinuousCoReviewCaseSensitivityCache = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) function Get-ContinuousCoReviewCaseFlippedName { # The opposite-case spelling of a name, or $null when the name carries no cased letter and so # cannot answer the question. param([Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Name) if ([string]::IsNullOrEmpty($Name)) { return $null } if ($Name -cne $Name.ToUpperInvariant()) { return $Name.ToUpperInvariant() } if ($Name -cne $Name.ToLowerInvariant()) { return $Name.ToLowerInvariant() } return $null } function Get-ContinuousCoReviewOrdinalUniquePath { # The ONE dedup for PATH collections. `Sort-Object -Unique -CaseSensitive` is not a substitute: # -CaseSensitive flips only the case flag and the comparison stays CULTURE-aware, so byte-distinct # but culture-equivalent names - composed versus decomposed Unicode spellings, which Git and macOS # both produce - collapse into one entry and the dropped path is never compared again # (DRIFT-198-I009-033). Deduping is Ordinal, and so is the ORDERING: these lists feed digests, and # a culture-dependent order would make one tree hash differently on two runners. # AllowEmptyString is load-bearing: callers hand this raw normalized lists that can contain empty # entries, and a Mandatory [string[]] rejects an empty ELEMENT at the binder before the body can # filter it. The filtering belongs here rather than at each call site - that is the point. param([Parameter(Mandatory)][AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Path) $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $kept = [Collections.Generic.List[string]]::new() foreach ($candidate in @($Path)) { if ([string]::IsNullOrWhiteSpace($candidate)) { continue } if ($seen.Add([string]$candidate)) { $kept.Add([string]$candidate) } } $kept.Sort([StringComparer]::Ordinal) return @($kept) } function Get-ContinuousCoReviewCaseVerdictFromListing { # Decide the volume's case rule from ONE entry whose spelling is AUTHORITATIVE because it came out # of a directory listing. $true = case-SENSITIVE, $false = case-INSENSITIVE, $null = this entry # cannot answer (its name carries no cased letter). # # Never pass a caller-supplied name here. Reading the caller's spelling back as if the filesystem # had confirmed it is precisely DRIFT-198-I009-032. param( [Parameter(Mandatory)][string]$Container, [Parameter(Mandatory)][AllowEmptyString()][string]$RealName, [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$ListedNames ) $flipped = Get-ContinuousCoReviewCaseFlippedName -Name $RealName if ([string]::IsNullOrEmpty($flipped)) { return $null } $listed = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) foreach ($name in $ListedNames) { if (-not [string]::IsNullOrEmpty($name)) { $null = $listed.Add($name) } } # BOTH spellings genuinely present in the listing: two distinct entries, which a case-folding # volume could not hold at once. This is the case the earlier revisions read backwards. if ($listed.Contains($RealName) -and $listed.Contains($flipped)) { return $true } # Only the real spelling is listed. Ask whether a lookup of the ABSENT spelling still resolves. # It resolves => the volume folded case to reach the listed entry => INSENSITIVE. # It does not => the volume keeps the two spellings apart => SENSITIVE. $candidate = Join-Path $Container $flipped if ([IO.Directory]::Exists($candidate) -or [IO.File]::Exists($candidate)) { return $false } return $true } function Get-ContinuousCoReviewPathCaseSensitive { # $true = case-SENSITIVE volume, $false = case-INSENSITIVE, $null = undetermined. param([Parameter(Mandatory)][AllowEmptyString()][string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } # Initialize the memo DEFENSIVELY rather than trusting the file-level assignment to have run in the # scope this function resolves `$script:` against. It does not always: a consumer that self-loads # this file from another tree (review-engine-resolution.ps1 loading it as # `continuous-co-review/path-identity.ps1`) can end up with the functions defined while the # top-level assignment landed in a different script scope, and under StrictMode reading an unset # `$script:` variable THROWS - which surfaced as the deployed co-review runtime failing to load at # all. Same family as DRIFT-198-I009-018/027/035: the primitive must be correct in whatever scope # it is loaded into, never only in the one its author had in mind. if (-not (Get-Variable -Name 'ContinuousCoReviewCaseSensitivityCache' -Scope Script -ErrorAction SilentlyContinue)) { $script:ContinuousCoReviewCaseSensitivityCache = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) } $probeDir = $null try { $probeDir = [IO.Path]::GetFullPath($Path) while (-not [string]::IsNullOrWhiteSpace($probeDir) -and -not [IO.Directory]::Exists($probeDir)) { $parent = [IO.Path]::GetDirectoryName($probeDir) if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $probeDir) { break } $probeDir = $parent } } catch { return $null } if ([string]::IsNullOrWhiteSpace($probeDir) -or -not [IO.Directory]::Exists($probeDir)) { return $null } if ($script:ContinuousCoReviewCaseSensitivityCache.ContainsKey($probeDir)) { return $script:ContinuousCoReviewCaseSensitivityCache[$probeDir] } # Determined by ASKING THE FILESYSTEM, with no subprocess. An earlier revision consulted # `git config core.ignorecase` first; that call is reached from the digest's per-path loop and # hung the Linux CI review suite silently, with the process killed and no failing assertion. # A direct probe measures the volume itself rather than git's cached init-time answer, works for # directories outside any repository (the external target root), and cannot block. It only # reads, so it stays safe against an OS-protected read-only reviewer target. # # THE RULE, and why the previous two revisions got it wrong (DRIFT-198-I009-026, -032): # every verdict must be derived from an on-disk spelling obtained by ENUMERATION. The caller's # spelling is not evidence. `[IO.Path]::GetFullPath` preserves whatever the caller typed and does # not canonicalise to the real entry, so a probe that flips the CALLER's leaf is comparing against # a name the filesystem may never have held - which is how a case-INSENSITIVE volume holding # `REPO`, asked about `repo`, was reported case-SENSITIVE. Given a real listed name and its # flipped spelling, two reads decide it: both spellings listed means two genuinely distinct # entries, which only a case-preserving volume can hold; exactly one listed, with the other still # RESOLVING, means the lookup folded case. $result = $null try { # (a) Prefer probing INSIDE the physical target. Its children's names come straight from the # directory listing, so no caller spelling can contaminate the answer. $childEntries = @([IO.Directory]::GetFileSystemEntries($probeDir)) $childNames = @($childEntries | ForEach-Object { [IO.Path]::GetFileName($_) } | Where-Object { -not [string]::IsNullOrEmpty($_) }) foreach ($childName in @($childNames | Select-Object -First 64)) { $result = Get-ContinuousCoReviewCaseVerdictFromListing -Container $probeDir -RealName $childName -ListedNames $childNames if ($null -ne $result) { break } } # (b) An empty target (or one whose entries carry no cased letter) still has its OWN entry in # the parent. Read the REAL spelling of that entry out of the parent's listing - never the # leaf the caller supplied - and apply the identical rule. if ($null -eq $result) { $parent = [IO.Path]::GetDirectoryName($probeDir) if (-not [string]::IsNullOrWhiteSpace($parent) -and [IO.Directory]::Exists($parent)) { $suppliedLeaf = [IO.Path]::GetFileName($probeDir.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)) $parentNames = @([IO.Directory]::GetFileSystemEntries($parent) | ForEach-Object { [IO.Path]::GetFileName($_) } | Where-Object { -not [string]::IsNullOrEmpty($_) }) $realLeaf = $null foreach ($name in $parentNames) { if ($name -ceq $suppliedLeaf) { $realLeaf = $name; break } } if ([string]::IsNullOrEmpty($realLeaf)) { foreach ($name in $parentNames) { if ([string]::Equals($name, $suppliedLeaf, [System.StringComparison]::OrdinalIgnoreCase)) { $realLeaf = $name; break } } } if (-not [string]::IsNullOrEmpty($realLeaf)) { $result = Get-ContinuousCoReviewCaseVerdictFromListing -Container $parent -RealName $realLeaf -ListedNames $parentNames } } } } catch { $result = $null } $script:ContinuousCoReviewCaseSensitivityCache[$probeDir] = $result return $result } function Get-ContinuousCoReviewPathComparison { # WhenUndetermined names the SAFE direction for THIS caller, because there is no single safe # default. A containment check that must REFUSE an aliased path needs variants treated as the # SAME so it rejects more; machinery stripping needs them DISTINCT so it can never remove real # source from the reviewed identity. Passing the wrong one is silently unsafe, so it is required. param( [Parameter(Mandatory)][AllowEmptyString()][string]$Path, [Parameter(Mandatory)][ValidateSet('same', 'distinct')][string]$WhenUndetermined ) $sensitive = Get-ContinuousCoReviewPathCaseSensitive -Path $Path if ($null -eq $sensitive) { return $(if ($WhenUndetermined -ceq 'same') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }) } return $(if ($sensitive) { [StringComparison]::Ordinal } else { [StringComparison]::OrdinalIgnoreCase }) } function Get-ContinuousCoReviewPathComparer { param( [Parameter(Mandatory)][AllowEmptyString()][string]$Path, [Parameter(Mandatory)][ValidateSet('same', 'distinct')][string]$WhenUndetermined ) $comparison = Get-ContinuousCoReviewPathComparison -Path $Path -WhenUndetermined $WhenUndetermined return $(if ($comparison -eq [StringComparison]::OrdinalIgnoreCase) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal }) } function ConvertTo-ContinuousCoReviewLiteralPathspec { # A literal repository identity must never reach Git as a glob: a legal directory name holding # pathspec metacharacters would otherwise select unrelated source. param([Parameter(Mandatory)][AllowEmptyString()][string]$Path) return ':(literal)' + (([string]$Path) -replace '\\', '/') } |