Private/FileTransfer/Resolve-VmFileEntries.ps1
|
<#
.SYNOPSIS Resolves a host-side wildcard into the entry shape Copy-VmFiles consumes. .DESCRIPTION Pure host-side helper. Expands the wildcard with Get-ChildItem, drops directories, computes the VM target path for each match (flatten or mirror-tree), and runs the pre-flight validation pass described in docs\dev\implementation\01 - bulk-vm-file-transfer\problem.md. No SSH client or file server is touched here. All shape checks happen before any returned entry could reach a transport step, which keeps the failure mode deterministic and reproducible without a live VM. .PARAMETER Pattern A host-side wildcard accepted by Get-ChildItem -Path (e.g. 'C:\src\*.json' or 'C:\src\*' with -Recurse). .PARAMETER TargetDir Absolute Linux directory on the VM under which every match lands. A trailing separator is tolerated and stripped. .PARAMETER Recurse Descend into subdirectories. .PARAMETER PreserveRelativePath When set, each match keeps its host path relative to the longest wildcard-free prefix of Pattern, mirrored under TargetDir. Otherwise every match is flattened to TargetDir / basename. .PARAMETER Owner chown argument applied uniformly to every entry. Defaults to 'root:root' to match Copy-VmFiles' default. .PARAMETER Mode chmod argument applied uniformly to every entry. Defaults to '0644' to match Copy-VmFiles' default. .OUTPUTS [PSCustomObject[]] with fields Source, Target, Owner, Mode - the exact shape Copy-VmFiles' -Entries parameter accepts. #> function Resolve-VmFileEntries { [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [string] $Pattern, [Parameter(Mandatory)] [string] $TargetDir, [switch] $Recurse, [switch] $PreserveRelativePath, [string] $Owner = 'root:root', [string] $Mode = '0644' ) # Derive the source root: the longest leading run of path components in # Pattern that contain no wildcard characters. This is the anchor for # -PreserveRelativePath, picked at a component boundary so we never # relativise mid-component (e.g. 'C:\src\foo*\x' anchors at 'C:\src', # not 'C:\src\foo'). Splitting on both separators lets callers pass # either flavour on Windows. $patternSegments = $Pattern -split '[\\/]' $rootSegments = @() foreach ($segment in $patternSegments) { if ($segment -match '[\*\?\[]') { break } $rootSegments += $segment } $sourceRoot = $rootSegments -join [IO.Path]::DirectorySeparatorChar # -File filters directories out at the source so a pattern that matches # only directories surfaces as the zero-files error below, matching the # contract in problem.md. $matched = @(Get-ChildItem -Path $Pattern -Recurse:$Recurse -File ` -ErrorAction SilentlyContinue) if ($matched.Count -eq 0) { throw "Resolve-VmFileEntries: no files matched pattern '$Pattern'." } $normalizedTargetDir = $TargetDir.TrimEnd('/', '\') $entries = @(foreach ($file in $matched) { if ($PreserveRelativePath) { # Strip the wildcard-free root so the remaining path captures # only the host subtree being mirrored. OrdinalIgnoreCase covers # Windows' case-insensitive filesystem without false positives. $relative = $file.FullName if ($sourceRoot -and $relative.StartsWith($sourceRoot, [StringComparison]::OrdinalIgnoreCase)) { $relative = $relative.Substring($sourceRoot.Length) } $relative = $relative.TrimStart('\', '/').Replace('\', '/') $target = "$normalizedTargetDir/$relative" } else { $target = "$normalizedTargetDir/$($file.Name)" } [PSCustomObject]@{ Source = $file.FullName Target = $target Owner = $Owner Mode = $Mode } }) # One uniform duplicate check covers both modes: flatten-mode basename # collisions across subtrees and preserve-mode collapses both surface # as duplicate Target values. $duplicates = $entries | Group-Object Target | Where-Object { $_.Count -gt 1 } if ($duplicates) { $names = ($duplicates | ForEach-Object { $_.Name }) -join "', '" throw "Resolve-VmFileEntries: duplicate VM target path(s): '$names'." } return ,$entries } |