TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Portable Paths You Can Trust in PowerShell: Cross-Platform Path Building and Validation

Path bugs are sneaky: a stray slash here, a missing root there, and your script breaks the moment it runs on a different OS. With PowerShell 7+ running on Windows, Linux, and macOS, you can write scripts that build, validate, and resolve paths consistently across environments. The key ideas are simple: use Join-Path to compose paths, keep separators implicit, pick platform-appropriate roots with $IsWindows/$IsLinux/$IsMacOS, and validate with Test-Path and Resolve-Path before you write.

Build paths the safe way

Never concatenate slashes. Let PowerShell handle separators and platform quirks for you. You'll get clearer, more robust code that travels well between Windows, Linux, and macOS.

Choose a cross-platform root

Pick a root directory appropriate for the scope and platform, then build relative structure on top. Start with a helper you can reuse everywhere:

function Get-AppRoot {
    [CmdletBinding()]
    param(
        [string]$Vendor = 'Acme',
        [ValidateSet('User','Machine')] [string]$Scope = 'Machine'
    )

    if ($Scope -eq 'User') {
        if ($IsWindows) {
            return (Join-Path $env:LOCALAPPDATA $Vendor)
        } elseif ($IsMacOS) {
            return (Join-Path (Join-Path $HOME 'Library/Application Support') $Vendor)
        } else {
            $xdg = $env:XDG_DATA_HOME
            if ([string]::IsNullOrWhiteSpace($xdg)) { $xdg = (Join-Path $HOME '.local/share') }
            return (Join-Path $xdg $Vendor)
        }
    }

    # Machine scope
    if ($IsWindows) {
        return (Join-Path $env:ProgramData $Vendor)
    } elseif ($IsMacOS) {
        return (Join-Path '/Library/Application Support' $Vendor)
    } else {
        return (Join-Path '/var' ($Vendor.ToLower()))
    }
}

Compose segments with Join-Path

Let Join-Path do the heavy lifting. It prevents doubled separators, normalizes output, and reads cleanly.

# Anti-pattern: manual concatenation (easy to get wrong across OSes)
$name = 'reports'
$date = '2025-11-05'
$bad  = "C:\\temp\\" + $name + "/" + $date  # Mixed separators, trailing slashes

# Preferred: Join-Path across all segments
$good = Join-Path -Path 'C:\\temp' -ChildPath $name
$good = Join-Path -Path $good -ChildPath $date

Here's a complete cross-platform example that chooses a machine-wide root, composes a date-based directory structure, creates it idempotently, writes a file, then resolves the full path for logging:

$root = if ($IsWindows) { Join-Path $env:ProgramData 'Acme' } else { '/var/acme' }
$parts = @('reports', (Get-Date -Format 'yyyy'), (Get-Date -Format 'MM'))
$path = $root
foreach ($p in $parts) { $path = Join-Path -Path $path -ChildPath $p }

New-Item -ItemType Directory -Path $path -Force | Out-Null

$file = Join-Path -Path $path -ChildPath 'summary.txt'
"Env=$env:COMPUTERNAME  When=$(Get-Date -Format 'u')" | Out-File -FilePath $file -Encoding utf8

$full = Resolve-Path -Path $file -ErrorAction Stop
Write-Host ("Created -> {0}" -f $full)

Validate and normalize with Test-Path and Resolve-Path

Before you write to disk, validate and normalize. Resolve-Path returns canonical paths, and Test-Path lets you assert existence or just syntax validity.

function Ensure-Directory {
    [CmdletBinding()] param([Parameter(Mandatory)][string]$Path)
    # Validate the path string is syntactically valid
    if (-not (Test-Path -LiteralPath $Path -IsValid)) {
        throw "Invalid path: $Path"
    }

    # Ensure directory exists
    if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
        New-Item -ItemType Directory -Path $Path -Force | Out-Null
    }

    # Return canonical path
    return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
}

$dir = Ensure-Directory (Join-Path (Get-AppRoot -Scope Machine) 'logs')
$log = Join-Path $dir 'run.log'

Patterns for real projects

Make path choices deliberate, predictable, and consistent with platform conventions and your deployment model.

User vs machine scope

Prefer user scope for CLI tools or developer machines and machine scope for services and agents. The following pattern gives you a portable Vendor/App directory in the right place:

param(
  [ValidateSet('User','Machine')]$Scope = 'User',
  [string]$Vendor = 'Acme',
  [string]$App = 'Tool'
)

$root = Get-AppRoot -Vendor $Vendor -Scope $Scope
$root = Ensure-Directory (Join-Path $root $App)

$cache = Ensure-Directory (Join-Path $root 'cache')
$data  = Ensure-Directory (Join-Path $root 'data')

Temp and cache directories

For ephemeral files, prefer platform temp directories. .NET's Path APIs are already cross-platform:

$tempBase = [System.IO.Path]::GetTempPath()   # Works everywhere
$tempDir  = Ensure-Directory (Join-Path $tempBase 'acme')
$tempFile = Join-Path $tempDir ('work_{0}.tmp' -f (Get-Date -Format 'yyyyMMdd_HHmmss'))

Containers and CI/CD runners

In containers and CI runners, write under the provided workspace or a mounted volume, and avoid hard-coded system locations.

# GitHub Actions: default to current workspace if not set
$workspace = if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Get-Location).Path }
$artifacts = Ensure-Directory (Join-Path $workspace 'artifacts')

# Containerized script: prefer $HOME or a bind-mounted path
$data = Ensure-Directory (Join-Path $HOME '.cache/acme')

# Dockerfile hint (for context):
# WORKDIR /app
# VOLUME /app/artifacts
# RUN pwsh -NoProfile -Command "New-Item -ItemType Directory -Path /app/artifacts -Force | Out-Null"

Logging and predictable outputs

When you need time-based directories, build deterministically with fixed formats instead of locale-dependent names:

$root = Get-AppRoot -Vendor 'Acme' -Scope Machine
$y = Get-Date -Format 'yyyy'
$m = Get-Date -Format 'MM'
$logs = Ensure-Directory (Join-Path (Join-Path (Join-Path $root 'reports') $y) $m)

Advanced tips and edge cases

UNC and network paths

UNC paths are just strings to Join-Path, so treat them like any other root. Keep the raw path literal and let Join-Path append segments.

$root = if ($IsWindows) { '\\fileserver\\share' } else { '/mnt/share' }
$drop = Ensure-Directory (Join-Path $root 'drop')
$target = Join-Path $drop 'payload.zip'

Literal paths vs globbing

When your file names contain wildcard characters ([], *, ?), use -LiteralPath to avoid globbing surprises.

$fname = 'data[final]*.csv'
$full  = Join-Path $drop $fname

# Treat as literal, not a wildcard
if (Test-Path -LiteralPath $full) {
    Get-Item -LiteralPath $full | Remove-Item -Force
}

Case sensitivity and normalization

Windows paths are case-insensitive; Linux/macOS are case-sensitive. Don't assume that 'Logs' and 'logs' are the same. Normalize the structure, not the case of actual file names, and avoid comparing paths by string unless you canonicalize them first with Resolve-Path.

$p1 = Resolve-Path -LiteralPath (Join-Path $root 'logs')
$p2 = Resolve-Path -LiteralPath (Join-Path $root 'Logs') -ErrorAction SilentlyContinue
# On Linux/macOS, $p2 may be $null if the differently-cased path doesn't exist.

Sanitize user input

Strip directory traversal from user-controlled input before joining. Then validate and resolve.

$unsafe = '..\\..\\invoice.pdf'
$clean  = [System.IO.Path]::GetFileName($unsafe)  # Drops directories
$final  = Join-Path $drop $clean
$final  = (Resolve-Path -LiteralPath (Ensure-Directory (Split-Path -Parent $final)) -ErrorAction Stop).Path | Out-Null

Test your path logic

Use Pester to assert behavior on each OS in your CI matrix.

Describe 'Portable paths' {
  It 'joins without duplicating separators' {
    (Join-Path '/var' 'log') | Should -Be '/var/log'
  }
  It 'keeps literals intact' {
    $p = Join-Path '/tmp' 'data[final]*.txt'
    Test-Path -LiteralPath $p -IsValid | Should -BeTrue
  }
}

Checklist: habits that stop path bugs

  • Always build with Join-Path; never concatenate slashes.
  • Choose roots with $IsWindows/$IsLinux/$IsMacOS and keep separators implicit.
  • Validate with Test-Path (-IsValid, -PathType) before writing.
  • Normalize with Resolve-Path for logging and comparisons.
  • Use -LiteralPath for names that include wildcards or special characters.
  • Pick user vs machine scope intentionally; keep writes inside known roots.
  • In CI/containers, write inside workspace or mounted volumes.

Adopt these patterns and you'll get fewer path bugs, clearer code, and predictable outputs across Windows, Linux, and macOS. Want more production-ready patterns? Explore the PowerShell Advanced Cookbook: PowerShell Advanced Cookbook.

← All Posts Home →