Skip to content

Guard lockIsFree()'s fopen against a symlinked .lock entry (#228) - #291

Merged
fdaviddpt merged 1 commit into
mainfrom
fix/228
Oct 4, 2026
Merged

fdaviddpt merged 1 commit into
mainfrom
fix/228

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

Follow-up from #225: TempCopySweeper::lockIsFree() opened $lockPath via fopen($lockPath, 'c') with no symlink/junction guard immediately before the call, the same TOCTOU shape #225 closed in the sibling method reclaimStaleOwnLock().

Adds if (self::isLinkOrJunction($lockPath) || !self::isRegularFile($lockPath)) { return false; } immediately before the fopen() call inside lockIsFree(), byte-for-byte mirroring reclaimStaleOwnLock()'s own pre-fopen guard from #225.

removeIfStale()'s own pre-existing is_file($lockPath) check already blocked the simple, non-race case the issue described; this closes the remaining TOCTOU window. The genuine mid-call symlink-swap race cannot be pinned deterministically in a unit test without disproportionate test-infra engineering (per the issue body itself), so instead of a race-pinning test, lockIsFree() (private) is tested directly via PHP Reflection -- the same convention already used in this suite (tests/Unit/RectorRunnerFallbackBranchesTest.php:69, tests/Unit/RectorRunnerTest.php, tests/Unit/Lsp/RectorDiagnosticsSourceTest.php:515, tests/Unit/RectorToolContainmentTest.php:91) -- proving the guard in isolation.

removeKnownContents()'s other $lockPath uses (is_file, file_get_contents, unlink) are deliberately left unguarded: none has fopen()'s create-on-missing-target danger, and unlink on a symlink removes the link itself, never the target. This scoping was confirmed independently by two spawned reviewers in self-review.

Testing

  • New test testLockIsFreeDoesNotFollowASymlinkedLockPath (must-not-fire): a symlink to a nonexistent external target; asserts lockIsFree() returns false and the external target is never created.
  • New test testLockIsFreeReturnsTrueForAGenuineFreeLockFile: a must-fire positive control pairing the above.
  • Red (pre-fix): testLockIsFreeDoesNotFollowASymlinkedLockPath failed -- "a symlinked lock path must never be treated as free to take / Failed asserting that true is false."
  • Green (post-fix): full TempCopySweeperTest file -- 16 tests (14 pre-existing + 2 new), 56 assertions, 2 skipped (pre-existing platform skips).
  • vendor/bin/phpstan analyse on both changed files: no errors.
  • Did not run the full suite (narrowed per repo policy); tests.full not-run.

removeKnownContents()'s other $lockPath uses (is_file, file_get_contents, unlink) are deliberately left unguarded: none has fopen()'s create-on-missing-target danger, and unlink on a symlink removes the link itself, never the target -- flagged by the auditor as a contextual observation, not ranked, not routed, pre-existing code outside this diff's scope.

Closes #228

🤖 Generated with Claude Code

TempCopySweeper::lockIsFree() opened `$lockPath` via fopen($lockPath, 'c')
with no check that the path is still a regular file -- the same shape
#225 closed in reclaimStaleOwnLock(). removeIfStale()'s own
is_file($lockPath) check already blocks the simple case, but a lockPath
swapped for a symlink in the window between that check and lockIsFree()'s
own fopen() could still make the sweeper create and probe/lock a file
anywhere this process can write.

lockIsFree() now re-checks isLinkOrJunction($lockPath) ||
!isRegularFile($lockPath) immediately before its own fopen(), mirroring
reclaimStaleOwnLock()'s guard. Tested directly against lockIsFree() via
reflection (it is private), the same way #225's own test exercises
reclaimStaleOwnLock() directly, since the actual TOCTOU race cannot be
pinned deterministically without disproportionate test-infra engineering.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
@fdaviddpt
fdaviddpt merged commit ff8a60a into main Oct 4, 2026
24 checks passed
@fdaviddpt
fdaviddpt deleted the fix/228 branch October 4, 2026 00:06
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.

lockIsFree()/removeIfStale() has the same unguarded fopen($lockPath) shape as #225's reclaimStaleOwnLock()

1 participant