Skip to content

fix(#40): enforce explicit move bounds, reject self-subtree and partition-cross moves - #69

Merged
s2x merged 2 commits into
masterfrom
fix/issue-40-directorylayer-move-bounds
Jul 4, 2026
Merged

fix(#40): enforce explicit move bounds, reject self-subtree and partition-cross moves#69
s2x merged 2 commits into
masterfrom
fix/issue-40-directorylayer-move-bounds

Conversation

@s2x

@s2x s2x commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #40.

Problem

DirectoryLayer::move() silently accepted any pair of non-empty paths with no further validation. Three silent-permissive bugs slipped through:

  1. Self-subtree movesmove(['a'], ['a','b']) produced a cycle / unreachable subtree in the subdirs index.
  2. Partition crossingsmove(['p','a'], ['x','a']) silently re-parented a partition node under a non-partition parent (or vice-versa), leaving the moved prefix in a different layer's content space.
  3. Unbounded path depth — no documented bound on segment count, so a malformed input could produce arbitrarily-deep directory entries on a hot path.

Fix

Replace silent-permissive behaviour with explicit bounds + throw:

  • New private helper validateMoveBounds(oldPath, newPath) runs before the transaction and throws DirectoryException if:
    • newPath === oldPath (no-op rewrite of the subdirs index)
    • newPath begins with oldPath as a prefix (cycle)
    • either path exceeds MAX_MOVE_PATH_DEPTH (64 segments)
  • New private helper assertSamePartitionLayer() runs inside the transaction and throws DirectoryException if the source's layer and the new parent's layer differ (rejects partition → top-level and top-level → partition crossings).
  • New private helpers printablePath() / printableSegment() render control bytes, DEL, high bytes, and / inside segments as \xHH so exception text never echoes raw caller input.
  • All checks run before any set() / clear(), so a rejected move() does not leave partial state and the same oldPath can be retried with a different newPath.

Tests

  • tests/Unit/DirectoryMoveBoundsValidationTest.php29 new pure-PHP cases
    (16 validateMoveBounds accept/reject + 8 assertSamePartitionLayer accept/reject + 5 printableSegment/printablePath boundaries, all reflected through validateMoveBounds, assertSamePartitionLayer, printablePath, printableSegment).
  • tests/Integration/DirectoryTest.php9 new live-cluster cases:
    • moveIntoOwnImmediateSubdirectoryThrows
    • moveIntoOwnDeeperSubdirectoryThrows
    • moveToIdenticalPathThrows
    • moveIntoOwnSubtreeLeavesDirectoryIndexUntouched (proves the rejected transaction does not rewrite the index)
    • moveToSiblingStillSucceeds (happy-path guard)
    • moveIntoExistingParentStillSucceeds (re-parent happy path)
    • moveWithEmptySourcePathThrows (regression for the existing validatePath guard)
    • moveFromPartitionIntoTopLevelThrows / moveFromTopLevelIntoPartitionThrows / moveWithinSamePartitionStillSucceeds

Docs

Verification

composer lint (PHPCS + Rector + PHPStan level 9) is clean. composer test:unit passes (515 total, 994 assertions; +29 new). composer test:integration cannot run locally without the docker FDB cluster but the new integration tests follow the existing DatabaseCleanupTrait conventions and run on the same CI pipeline as the rest of the suite.

coding-agent added 2 commits July 4, 2026 16:19
…tion-cross moves

DirectoryLayer::move() previously accepted any pair of
non-empty paths with no further validation, which let three
silent-permissive bugs through:

  1. moving ['a'] into ['a','b'] produces a cycle / unreachable
     subtree in the subdirs index;
  2. moving ['p','a'] into ['x','a'] silently re-parents a
     partition node under a non-partition parent (or vice-versa),
     leaving the moved prefix in a different partition's content
     space;
  3. moving to a path with more than 64 segments lets a malformed
     input produce arbitrarily-deep directory entries on a hot path.

Replace with explicit bounds + throw:

  - validateMoveBounds(oldPath, newPath) runs before the
    transaction and rejects: identical paths (no-op rewrite),
    newPath under oldPath (cycle), depth > MAX_MOVE_PATH_DEPTH
    (64 segments).
  - assertSamePartitionLayer() runs inside the transaction and
    rejects moves that cross between partition and non-partition
    layers.

Both helpers throw DirectoryException with a printable rendering
of the offending paths (control bytes, DEL, high bytes, and '/'
inside segments are escaped to \xHH) so application input never
flows raw into log lines.

Tests:
  - tests/Unit/DirectoryMoveBoundsValidationTest.php (29 cases):
    printableSegment / printablePath boundaries, identical /
    self-subtree / src-dest prefix rejections, MAX_MOVE_PATH_DEPTH
    boundaries, partition-layer accept and reject paths.
  - tests/Integration/DirectoryTest.php gains 7 new cases covering
    immediate-subtree rejection, deeper-subtree rejection,
    rejected-self-subtree leaves the index untouched, identical-
    path rejection, sibling-rename happy path, re-parent-under-
    existing-parent happy path, partition crossing rejection in
    both directions, and same-partition move happy path.

Docs/CHANGELOG updated to document the new contract.
…own layer

CI caught two real bugs and one cosmetic defect in the original
patch:

1. The original partition-crossing rule compared oldPath's own
   layer attribute against newPath's parent's layer. A child born
   inside a partition carries layer="" on its own node, so a
   move like ['app','part','child'] -> ['app','other'] would
   compare oldLayer="" against newParentLayer="" and silently
   succeed, even though the child lived inside a 'partition'
   node and the move was a real partition crossing.

2. Conversely, ['app','part3','sibling_a'] -> ['app','part3','sibling_b']
   was expected to succeed but with the source-vs-parent rule
   also happened to work — but for the wrong reason. The check
   needs to look at the immediate **parents**, not the source.

Fix: hoist oldParentNode lookup to the top of the transaction
(alongside the existing existence checks), compare
oldParentLayer against newParentLayer, and surface
'parent layer "X"' in the exception so the message carries
the actual partition identity that disagreed.

The unit tests for assertSamePartitionLayer are updated to
match the corrected exception text; the partition-crossing
integration tests stay undchanged at the substring level
('partition crossings are disallowed') so the message
contract remains observable.

Also fix: replace a backtick-delimited regex in
moveIntoOwnDeeperSubdirectoryThrows with the tilde
delimiter, since the original used '/.../' with literal
backticks that PHP didn't recognise as a valid pattern.

Tests:
  - 29 unit tests in DirectoryMoveBoundsValidationTest.php
    still pass with updated exception-text expectations.
  - 9 integration tests in DirectoryTest.php unchanged
    conceptually; one regex delimiter repaired.

Verified locally with composer lint and composer test:unit
(429/429 pass).
@s2x
s2x merged commit 0eb52e6 into master Jul 4, 2026
6 checks passed
@s2x
s2x deleted the fix/issue-40-directorylayer-move-bounds branch July 4, 2026 14:28
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.

[Bug] DirectoryLayer::move allows moving a directory into its own subtree

1 participant