# How Pi-Web's `samePath()` Handles Windows vs POSIX Path Security

> Learn how Pi-Web's samePath handles Windows and POSIX path security with case-insensitive and sensitive comparisons for secure file path handling.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: deep-dive
- Published: 2026-08-18

---

**`samePath()` in Pi-Web performs case-insensitive comparisons on Windows and case-sensitive comparisons on POSIX systems, using `normalizeForComparison()` to create stable path representations before checking equality.**

Pi-Web's filesystem security relies on cross-platform path comparison logic implemented in [`lib/paths.ts`](https://github.com/agegr/pi-web/blob/main/lib/paths.ts) and [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts). These modules ensure that path validation behaves correctly whether the runtime is Windows (with drive letters, backslashes, and case-insensitive semantics) or POSIX (forward slashes, case-sensitive). This article examines how `samePath()` and related functions handle platform differences to prevent directory traversal attacks.

## Detecting Windows-Style Paths

Before any comparison occurs, Pi-Web identifies which platform rules apply. The `isWindowsAbsolutePath()` function in [`lib/paths.ts`](https://github.com/agegr/pi-web/blob/main/lib/paths.ts) uses regex detection to flag Windows-specific paths.

```typescript
// lib/paths.ts#L24-L28
function isWindowsAbsolutePath(p: string): boolean {
  return /^[a-zA-Z]:[\\\/]/.test(p) || p.startsWith('\\\\') || p.startsWith('//');
}

```

This check triggers Windows handling when:
- A drive letter prefix exists (`C:\` or `D:/`)
- A UNC path begins with `\\` or `//`

The detection is used by both `samePath()` and `isPathWithinRoots()` to choose appropriate normalization strategies.

## Canonical Path Forms and Internal Representation

Pi-Web maintains two path representations to prevent string mismatches:

| Form | Function | Purpose |
|------|----------|---------|
| **Native** | `toNativePath()` | Filesystem operations, UI display |
| **Slash** | `toSlashPath()` | Internal storage, allowed-roots sets |

```typescript
// lib/paths.ts#L30-L45
export function toSlashPath(p: string): string {
  return p.replace(/\\/g, '/');
}

export function toNativePath(p: string): string {
  return process.platform === 'win32' 
    ? p.replace(/\//g, '\\') 
    : p;
}

```

Allowed roots are always stored in slash form. This design lets Git's POSIX-style outputs (`D:/repo`) match against Windows runtime paths without ambiguity.

## The `samePath()` Equality Function

The `samePath()` function implements the core path comparison logic in [`lib/paths.ts`](https://github.com/agegr/pi-web/blob/main/lib/paths.ts) (lines 56-71):

```typescript
export function samePath(a: string, b: string): boolean {
  // 1. Fast-path identity check
  if (a === b) return true;
  
  // 2. Empty path guard
  if (!a || !b) return false;
  
  // 3. Normalize both paths
  const na = normalizeForComparison(a);
  const nb = normalizeForComparison(b);
  
  // 4. Platform-sensitive comparison
  if (process.platform === 'win32') {
    return na.toLowerCase() === nb.toLowerCase();
  }
  
  // 5. POSIX: verbatim comparison
  return na === nb;
}

```

The comparison proceeds through five stages:
1. **Identity shortcut** — Identical strings short-circuit immediately
2. **Validity check** — Empty paths are rejected as unequal
3. **Normalization** — `normalizeForComparison()` creates stable forms
4. **Windows case folding** — `toLowerCase()` enables `C:\Repo` == `c:\repo`
5. **POSIX exact match** — Case and encoding must match exactly

## Path Normalization for Stable Comparison

The private `normalizeForComparison()` helper (lines 47-53) prepares paths for reliable comparison:

```typescript
function normalizeForComparison(p: string): string {
  // Use native path module for platform-correct normalization
  const normalized = path.normalize(p);
  
  // Remove trailing separators
  const trimmed = normalized.replace(/[\\\/]+$/, '');
  
  // Strip root for relative comparison stability
  return trimmed;
}

```

This removes the trailing slash differences that would otherwise cause `D:\repo` and `D:\repo\` to be treated as different locations.

## Containment Testing with `isPathWithinRoots()`

For security boundary checks, [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts) provides `isPathWithinRoots()` (lines 10-22). This extends `samePath()` logic to multi-root containment tests:

```typescript
export function isPathWithinRoots(
  target: string, 
  roots: Set<string>
): boolean {
  for (const root of roots) {
    // Determine which platform rules apply
    const useWin32 = isWindowsAbsolutePath(target) || 
                     isWindowsAbsolutePath(root);
    
    const resolver = useWin32 ? path.win32 : path;
    
    // Resolve to absolute paths
    const resolvedTarget = resolver.resolve(target);
    const resolvedRoot = resolver.resolve(root);
    
    // Apply case-insensitivity when Windows rules active
    const [a, b] = useWin32 
      ? [resolvedTarget.toLowerCase(), resolvedRoot.toLowerCase()]
      : [resolvedTarget, resolvedRoot];
    
    // Ensure root ends with separator for prefix safety
    const rootPrefix = b.endsWith(resolver.sep) ? b : b + resolver.sep;
    
    // Check equality or directory containment
    if (a === b || a.startsWith(rootPrefix)) return true;
  }
  
  return false;
}

```

Key security features:
- **Context-aware resolver selection** — Uses `path.win32` when either path is Windows-absolute
- **Root separator enforcement** — Prevents prefix attacks like `/allowed` matching `/allowed-evil`
- **Symlink-aware variant** — `isExistingPathWithinRoots()` (lines 24-42) calls `fs.realpathSync()` before containment checks

## Practical Usage Example

```typescript
import { isPathWithinRoots, isExistingPathWithinRoots } from "@/lib/path-security";
import { toSlashPath, samePath } from "@/lib/paths";

// Internal storage uses slash form
const allowedRoots = new Set([
  toSlashPath("D:/repo"),           // Windows root
  toSlashPath("/home/user/project") // POSIX root
]);

// User input from HTTP request
const userPath = "D:\\repo\\src\\index.ts";

// Convert to internal representation
const normalized = toSlashPath(userPath);

// Lexical containment check
const allowed = isPathWithinRoots(normalized, allowedRoots);
console.log(allowed); // true

// Direct equality check
console.log(samePath("D:\\Repo", "d:/repo")); // true on Windows, false on POSIX

// Symlink-resolved containment
const realAllowed = isExistingPathWithinRoots(userPath, allowedRoots);

```

## Platform Differences Summary

| Aspect | Windows Behavior | POSIX Behavior |
|--------|---------------|----------------|
| **Separators** | `\` and `/` equivalent | `/` only |
| **Case sensitivity** | Insensitive (`samePath()` uses `toLowerCase()`) | Sensitive (verbatim compare) |
| **Absolute detection** | Drive letter or UNC prefix | Leading `/` |
| **Path module** | `path.win32` when detected | Default `path` |

## Summary

- **`samePath()`** implements platform-aware equality using normalization plus case-folding on Windows
- **`isWindowsAbsolutePath()`** detects drive-letter and UNC patterns to select appropriate rules
- **Dual path forms** (native vs slash) prevent representation mismatches in internal storage
- **`isPathWithinRoots()`** applies the same Windows/POSIX logic to multi-root containment tests
- **Symlink resolution** via `isExistingPathWithinRoots()` closes time-of-check-to-time-of-use vulnerabilities

## Frequently Asked Questions

### How does `samePath()` handle case differences on Windows?

`samePath()` calls `toLowerCase()` on both normalized paths when `process.platform === 'win32'`. This makes `C:\Repo`, `c:\repo`, and `C:/REPO` compare as equal. On POSIX systems, the comparison remains case-sensitive to match filesystem behavior.

### What happens if one path uses backslashes and another uses forward slashes?

The `normalizeForComparison()` helper calls `path.normalize()`, which converts separators to the platform default. When Windows rules are active (detected via `isWindowsAbsolutePath()`), `path.win32.normalize()` handles both separator types. The subsequent comparison operates on normalized strings where separator differences are resolved.

### Why does `isPathWithinRoots()` check both target and root for Windows patterns?

A POSIX-style root like `/mnt/d/repo` might contain a Windows-absolute target `D:\repo`. The `||` condition ensures Windows rules apply whenever either path requires them. This supports WSL and Docker scenarios where path representations mix conventions.

### How does Pi-Web prevent directory traversal through case variations?

On Windows, case folding normalizes variations like `CONFIG` and `config` to the same string. On POSIX, case differences create distinct paths. The containment check adds a mandatory separator after the root prefix, so `/allowed` cannot match `/allowed-anything` through prefix spoofing.