Skip to content

fix: align posix/windows path handling and enable windows ci#14

Merged
steipete merged 5 commits into
mainfrom
ci/windows-runner
May 8, 2026
Merged

fix: align posix/windows path handling and enable windows ci#14
steipete merged 5 commits into
mainfrom
ci/windows-runner

Conversation

@sjf
Copy link
Copy Markdown
Contributor

@sjf sjf commented May 7, 2026

Summary

This PR makes four logically separate changes that travel together because the CI matrix change is what surfaced the rest:

  1. Library: drop spurious ..-prefix rejection in the POSIX write helper that produced false positives for legitimate filenames and diverged from the Windows write path.
  2. Library: bug fixes to expandHomePrefix so it produces native paths instead of mixed-separator strings, and stops silently dropping leading/trailing whitespace from real filenames.
  3. Tests: Windows-aware path assertions for drive letters, separators, file modes, and unsupported-platform rejections.
  4. CI: run the check job on windows-latest in addition to ubuntu-latest.

1. Drop spurious ..-prefix rejection in the POSIX write helper

resolvePinnedWriteTargetInRoot (the POSIX write path) rejected any input whose path.relative(root, resolved) happened to start with the two characters .., even when the resolved absolute path was fully inside the root. That matched legitimate filenames such as ..%2fpwned.txt (a single literal segment) and ..%00/pwned.txt (two literal segments) — neither of which actually escapes the root — and produced a misleading outside-workspace error. The Windows write path never had this guard, so the same payloads were already accepted there; the result was a platform divergence around literal ..-prefixed names.

The real escape boundary is path.resolve + isPathInside, applied upstream by resolvePathInRoot on every code path. The extra startsWith("..") check added no security value: any input that genuinely escapes the root is already rejected by isPathInside. The check is removed; path.isAbsolute(relativeResolved) is kept since absolute relative-paths still need to fail closed.

2. Bug fixes to expandHomePrefix

Two real defects in src/home-dir.ts:

Mixed separators on Windows. The previous implementation used a regex plus input.replace to swap the leading ~ with the home directory, leaving the rest of the input untouched:

return input.replace(/^~(?=$|[\\/])/, home);

On Windows that produced outputs like C:\Users\xxx/file from ~/file — technically usable, but inconsistent with every other path the library returns. The function now splits the input into segments via path.normalize + split(path.sep) and rejoins via path.join, so the result always uses native separators. As a side effect, on POSIX the function correctly leaves ~\foo alone — \ is a literal name character on POSIX, not a separator, so it never indicated a home-prefixed path.

Silent whitespace stripping. resolveHomeRelativePath and resolveOsHomeRelativePath called input.trim() before processing, which silently corrupted any path that legitimately contained leading or trailing whitespace. Whitespace is a valid filename character on both POSIX (NTFS, ext4, etc.) and Windows. The trim was unjustified: env-var inputs are already trimmed upstream via normalizeOptionalString before reaching the resolver, so the only callers actually affected by the trim were direct path callers — and for them the trim was wrong. Both functions now process the input verbatim. The corresponding test assertions that codified the trim have been removed.

The same segment-based check (segments[0] !== "~") is applied to both resolveHomeRelativePath and resolveOsHomeRelativePath for consistency with expandHomePrefix.

3. Windows-aware test assertions

Several tests embedded POSIX path assumptions in their assertions. The library code was already correct on Windows; only the tests needed updating to express the right expectation per platform.

The categories of fix:

  • Drive letters. Tests built absolute roots via path.join(path.sep, "tmp", "root"), which produces \tmp\root on Windows. Functions under test (resolveSafeBaseDir, path.resolve, etc.) prepend the cwd's drive letter, yielding C:\tmp\root. The fix is to construct the test root via path.resolve so the drive letter is present from the start; comparisons then succeed on both platforms.
  • Path separators. Tests matched outputs against regexes containing literal / or compared against strings built with path.join("/...", ...). On Windows the actual separator is \. The fix is either to decompose results with path.dirname / path.basename (avoiding the separator entirely), or to compare against expected values built with the same path utilities the implementation uses.
  • Filename character semantics. path.basename treats \ as a separator on Windows and as a literal name character on POSIX. One sanitizer test asserted the POSIX outcome unconditionally; that assertion is now POSIX-only with a comment explaining that \ cannot appear in a Windows filename anyway.
  • POSIX file modes. Tests asserted (stat.mode & 0o777) === 0o600. POSIX modes do not fully apply on Windows, where Node returns 0o666 / 0o444 based on the read-only bit. The mode assertions are guarded behind process.platform !== "win32" so the surrounding behaviour the tests actually target still runs on both platforms.
  • unsupported-platform rejections. The pinned filesystem helper throws code: "unsupported-platform" on Windows by design (src/pinned-python.ts). Tests that exercise it through long happy-paths are now skipIf(win32); tests where the unsupported operation is the entire point are split into matched runIf(!win32) / runIf(win32) pairs, with the Windows variant asserting the documented error code. The shared expectFsSafeCode helper accepts unsupported-platform as an additional valid code on Windows; a new expectedFsSafeCode(code) helper substitutes for .rejects.toMatchObject({ code }) sites.
  • Secure-file ACL gap. readSecureFile fails closed on Windows with permission-unverified because there is no portable ACL equivalent (src/secure-file.ts:177). The "reads from a validated file handle" test is now split: a POSIX happy-path test and a Windows test asserting the documented error code.
  • file:// URLs. The local file URL test built its input by string-concatenating a POSIX-style path; on Windows that produced URLs without drive letters which Node rejects. The assertion now uses hardcoded platform-specific input/output pairs so it verifies safeFileURLToPath directly without depending on Node URL helpers.
  • Recategorized payload corpus. ..%2fpwned.txt and ..%00/pwned.txt move from SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS to LITERAL_SUSPICIOUS_WRITE_PAYLOADS (accepted on both platforms now that §1 lands). ..\pwned.txt moves to POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS (literal on POSIX, real traversal on Windows). The empty SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS array is removed.

No POSIX behaviour is changed by these test edits.

4. CI: enable windows-latest

.github/workflows/ci.yml changes:

  • The actionlint step is split into a dedicated lint-workflows job.
  • The check job becomes an OS matrix (ubuntu-latest, windows-latest) with fail-fast: false, so a Windows failure does not cancel the Linux run.
  • The check timeout is raised to 20 minutes to accommodate the slower Windows runner (pnpm install + tsc + vitest).

The repository's security model documents Windows-specific behaviour — no O_NOFOLLOW, Node-only fallbacks for fd-relative POSIX hardening, Windows ACL inspection in secure-file and permissions — but until now CI only ran on Linux. With this change, the documented fallback paths are exercised on every PR.

Result

Platform Before After
Windows 11 (arm64), Node 24.15.0, Developer Mode on 39 failed 254 passed, 0 failed, 66 skipped
POSIX unchanged unchanged

Test plan

  • pnpm check on Windows 11 (arm64), Node 24.15.0, Developer Mode enabled — fully green
  • First CI run on this branch passes the Windows job
  • CI continues to pass on the Linux job

@sjf sjf closed this May 7, 2026
@sjf sjf reopened this May 7, 2026
@sjf sjf force-pushed the ci/windows-runner branch 8 times, most recently from ae55e57 to 513b3ee Compare May 7, 2026 21:47
@sjf sjf closed this May 7, 2026
@sjf sjf force-pushed the ci/windows-runner branch from 513b3ee to c7ccb99 Compare May 7, 2026 21:56
Run the check job on windows-latest in addition to ubuntu so the
windows code paths (no O_NOFOLLOW, node fallbacks for fd-relative
ops, ACL inspection) are exercised on every PR rather than only
documented.

Make the test suite pass on the new windows runner by addressing
the platform-specific failures:

- Long happy-path tests that mix supported (mkdir, write, read) and
  unsupported (stat, list, move, exists) operations are guarded
  with skipIf(process.platform === "win32") since the pinned
  filesystem helper throws "unsupported-platform" on win32 by
  design (src/pinned-python.ts).
- Short focused tests where the unsupported operation is the whole
  point (pinned-python, pinned-write-fallback-coverage,
  write-boundary-bypass symlink-move) split into runIf(non-win32)
  and runIf(win32) tests, with the windows variant asserting
  unsupported-platform.
- The expectFsSafeCode helper accepts unsupported-platform on
  windows; new expectedFsSafeCode helper substitutes for
  per-rejects.toMatchObject sites where the windows code differs
  from posix (e.g. path-alias / not-found returning
  unsupported-platform via the helper layer).
- secure-file-reads test split into a posix happy-path runIf and a
  windows runIf that asserts permission-unverified, since ACL
  inspection has no portable equivalent on windows
  (src/secure-file.ts:177).
- safeFileURLToPath test uses hardcoded platform-specific input/
  output instead of building the URL via pathToFileURL+fileURLToPath
  so the assertion verifies the function directly.
- Fix expandHomePrefix to normalize path separators by splitting via
  path.normalize + path.sep and rejoining via path.join. Apply the
  same segment-based check to resolveHomeRelativePath and
  resolveOsHomeRelativePath. Drop input.trim() — whitespace is a
  valid filename character on both platforms and env-var inputs are
  already trimmed upstream via normalizeOptionalString.
- coverage-more's "normalizes empty temp names" decomposes the
  result with path.dirname/path.basename instead of regex-matching
  a path-separator literal.
- extracted-helpers' path-helpers test builds its root with
  path.resolve so the drive letter is present on windows.
- additional-boundary-bypass guards its "..\evil.txt" sanitizer
  assertion behind a non-win32 check (windows reserves "\" as a
  path separator and cannot have it in a filename).
- coverage-more's sibling temp test guards just the posix file-mode
  assertion (stat.mode & 0o777 === 0o600), which has no analog on
  windows. The syncing behaviour the test actually targets still
  runs on both platforms.
- Raise test/new-primitives.test.ts size budget to 1500 to
  accommodate the secure-file-reads test split.

After: 253 passed, 1 failed, 66 skipped on windows-11-arm64. The
single remaining failure is a separate library-side gap (a
SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS payload resolves on windows
instead of rejecting) and will be tracked in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sjf sjf reopened this May 7, 2026
@sjf sjf changed the title ci: run check on windows-latest in addition to ubuntu ci+test: run check on windows-latest and pass on the new runner May 7, 2026
@sjf sjf changed the title ci+test: run check on windows-latest and pass on the new runner fix: align posix/windows path handling and enable windows ci May 7, 2026
@sjf sjf force-pushed the ci/windows-runner branch 3 times, most recently from c102978 to d73dc91 Compare May 7, 2026 23:12
The posix write helper rejected any path whose computed
path.relative(root, resolved) string-began with "..", even when the
resolved path was fully inside the root. That matched literal
filenames whose first segment merely starts with the two characters
".." (e.g. "..%2fpwned.txt", "..%00/pwned.txt") and produced an
"outside-workspace" error for paths that do not actually escape
the root. The real boundary is already enforced upstream by
resolvePathInRoot's path.resolve + isPathInside check, so the
extra startsWith("..") guard added no security value while
introducing platform divergence (windows did not have it). Drop the
guard, keep the path.isAbsolute check.

Recategorize the three former SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS
test inputs so the test reflects what each platform actually does:

- "..%2fpwned.txt" and "..%00/pwned.txt" are literal names that
  resolve fully inside root on both platforms; move them to
  LITERAL_SUSPICIOUS_WRITE_PAYLOADS (accepted everywhere).
- "..\pwned.txt" is a real traversal on windows where "\\" is a
  separator, but a literal filename on posix where "\\" is a regular
  name character; move it to POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS
  (accepted on posix, rejected on windows).

The literal-directory check in the same test uses fsp.stat on
windows since safeRoot.list goes through the pinned helper that is
unavailable on win32, matching the pattern already used a few lines
up. Bump the per-test timeout to 15s for slow windows fs under
parallel test load.

Drop a stale explanatory comment in expandHomePrefix.

After: 254 passed, 0 failed, 66 skipped on windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sjf sjf force-pushed the ci/windows-runner branch from d73dc91 to 4a1e5d8 Compare May 7, 2026 23:16
sjf and others added 3 commits May 7, 2026 16:23
On windows fs.realpathSync and fs.realpath (async) can disagree on
8.3 short-name canonicalization. The github actions windows runner
exposes this: fs.realpathSync returns "C:\Users\RUNNER~1\..."
while fs.realpath returns "C:\Users\runneradmin\...". Tests that
compare a sync helper's output against await fs.realpath fail with
the same path printed in two forms.

Compare against fs.realpathSync (imported as realpathSync from
node:fs) on both sides so the test exercises the same canonical
form regardless of which short-name configuration the runner has.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@steipete steipete merged commit b5ad5e9 into main May 8, 2026
6 checks passed
@steipete steipete deleted the ci/windows-runner branch May 8, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants