Skip to content
31 changes: 29 additions & 2 deletions src/wp-includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -2184,7 +2184,24 @@ function path_join( $base, $path ) {
* @return string Normalized path.
*/
function wp_normalize_path( $path ) {
$wrapper = '';
$path = (string) $path;

static $hot = array();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these could use explanation. happy to add or propose some if you prefer and are willing to wait a few days.

static $warm = array();
static $max = 100;

if ( isset( $hot[ $path ] ) ) {
return $hot[ $path ];
}

if ( isset( $warm[ $path ] ) ) {
$hot[ $path ] = $warm[ $path ];
unset( $warm[ $path ] );
return $hot[ $path ];
}

$original_path = $path;
$wrapper = '';

if ( wp_is_stream( $path ) ) {
list( $wrapper, $path ) = explode( '://', $path, 2 );
Expand All @@ -2203,7 +2220,17 @@ function wp_normalize_path( $path ) {
$path = ucfirst( $path );
}

return $wrapper . $path;
$value = $wrapper . $path;

$hot[ $original_path ] = $value;

// Rotate segments when hot is full.
if ( count( $hot ) >= $max ) {
$warm = $hot;
$hot = array();
}

return $value;
}

/**
Expand Down
139 changes: 139 additions & 0 deletions tests/phpunit/tests/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,145 @@
array( 'php://input', 'php://input' ),
array( 'http://example.com//path.ext', 'http://example.com/path.ext' ),
array( 'file://c:\\www\\path\\', 'file://C:/www/path/' ),

// Edge cases.
array( '', '' ), // Empty string should return empty string.
array( 123, '123' ), // Integer should be cast to string.
);
}

/**
* Tests that wp_normalize_path() works with objects that have __toString().
*
* This is important because the function uses a static cache, and the input
* must be cast to string before being used as an array key.
*
* @ticket 64538
*/
public function test_wp_normalize_path_with_stringable_object() {
$stringable = new class() {
public function __toString() {
return '/var/www/html\\test';
}
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a more representative test might be an SplFileInfo instance, since that is actually used in Core. as discovered in #10781 we can fix that currently-improper call in another patch.


$this->assertSame( '/var/www/html/test', wp_normalize_path( $stringable ) );
}

/**
* Tests that wp_normalize_path() returns consistent results on repeated calls.
*
* The function uses a static cache, so this verifies cache behavior.
*
* @ticket 64538
*/
public function test_wp_normalize_path_returns_consistent_results() {
$path = 'C:\\www\\path\\';

$first_call = wp_normalize_path( $path );
$second_call = wp_normalize_path( $path );
$third_call = wp_normalize_path( $path );

$this->assertSame( $first_call, $second_call, 'Second call should return same result as first.' );
$this->assertSame( $second_call, $third_call, 'Third call should return same result as second.' );
$this->assertSame( 'C:/www/path/', $first_call, 'Normalized path should match expected value.' );
}

/**
* Tests that wp_normalize_path() cache rotation works correctly.
*
* The function uses a two-tier cache (hot/warm) with max 100 entries in hot.
* This verifies that after exceeding the cache size, both old and new paths
* still return correct results.
*
* @ticket 64538
*/
public function test_wp_normalize_path_cache_rotation() {
$paths = array();
$expected = array();

// Generate 150 unique paths to exceed the 100-entry hot cache limit.
for ( $i = 0; $i < 150; $i++ ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while I wouldn’t normally want to suggest poking internally into a function like this, I think this is an appropriate test due to the fragile nature of the cache.

to that end, hard-coding 150 here and leaving a comment decouples the test from the code. what do you think about reading the max and basing the test off of it? I would hate for someone to come in and retune the max in a way that makes the tests pass when they should fail.

$function_reflector = new ReflectionFunction( '\wp_normalize_path' );
$max_cache_size     = $function_reflector->getStaticVariables()['max'] ?? null;
$this->assertIsInt( $max_cache_size, 'Should have read max cache size from static variable "$max" inside "wp_normalize_path()" but found none: Check test setup.' );

for ( $i = 0; $i < 2 * $max_cache_size; $i++ ) {
	…
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ha! I see that you used this in the next test case but not here. LLMs being a little flight-of-mind on this?

$paths[ $i ] = "/var/www/test-{$i}\\subdir\\";
$expected[ $i ] = "/var/www/test-{$i}/subdir/";
}

// First pass: normalize all paths (fills hot, triggers rotation to warm).
$results_first_pass = array();
foreach ( $paths as $i => $path ) {
$results_first_pass[ $i ] = wp_normalize_path( $path );
}

// Verify all first pass results are correct.
foreach ( $results_first_pass as $i => $result ) {
$this->assertSame(
$expected[ $i ],
$result,
"First pass: Path {$i} should be normalized correctly."
);
}

// Second pass: verify old paths (now in warm) still return correct results.
// Access early paths which should have rotated to warm cache.
for ( $i = 0; $i < 50; $i++ ) {
$this->assertSame(
$expected[ $i ],
wp_normalize_path( $paths[ $i ] ),
"Second pass: Early path {$i} should still return correct result from warm cache."
);
}

// Verify recent paths (should be in hot cache) also work.
for ( $i = 100; $i < 150; $i++ ) {
$this->assertSame(
$expected[ $i ],
wp_normalize_path( $paths[ $i ] ),
"Second pass: Recent path {$i} should return correct result from hot cache."
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same note about basing our test on the actual configured max cache sizes in these two loop vs. hard-coding one.

}

/**
* Tests that wp_normalize_path() cache segments do not exceed the maximum size.
*
* The function uses a two-tier cache with a max of 100 entries per segment.
* This verifies the cache remains bounded after processing many unique paths.
*
* @ticket 64538
*/
public function test_wp_normalize_path_cache_segment_limits() {
// Generate 250 unique paths to ensure multiple cache rotations.
for ( $i = 0; $i < 250; $i++ ) {
wp_normalize_path( "/unique/path/number-{$i}\\file.php" );
}

// Use reflection to inspect the static cache variables.
$reflection = new ReflectionFunction( 'wp_normalize_path' );

Check warning on line 338 in tests/phpunit/tests/functions.php

View workflow job for this annotation

GitHub Actions / Coding standards / PHP checks

Equals sign not aligned with surrounding assignments; expected 2 spaces but found 5 spaces
$static_vars = $reflection->getStaticVariables();

Check warning on line 339 in tests/phpunit/tests/functions.php

View workflow job for this annotation

GitHub Actions / Coding standards / PHP checks

Equals sign not aligned with surrounding assignments; expected 1 space but found 4 spaces
$hot_count = count( $static_vars['hot'] );

Check warning on line 340 in tests/phpunit/tests/functions.php

View workflow job for this annotation

GitHub Actions / Coding standards / PHP checks

Equals sign not aligned with surrounding assignments; expected 3 spaces but found 6 spaces
$warm_count = count( $static_vars['warm'] );

Check warning on line 341 in tests/phpunit/tests/functions.php

View workflow job for this annotation

GitHub Actions / Coding standards / PHP checks

Equals sign not aligned with surrounding assignments; expected 2 spaces but found 5 spaces
$max = $static_vars['max'];

Check warning on line 342 in tests/phpunit/tests/functions.php

View workflow job for this annotation

GitHub Actions / Coding standards / PHP checks

Equals sign not aligned with surrounding assignments; expected 9 spaces but found 12 spaces

// Verify hot cache does not exceed max.
$this->assertLessThanOrEqual(
$max,
$hot_count,
"Hot cache ({$hot_count} entries) should not exceed max ({$max})."
);

// Verify warm cache does not exceed max.
$this->assertLessThanOrEqual(
$max,
$warm_count,
"Warm cache ({$warm_count} entries) should not exceed max ({$max})."
);

// Verify total cached entries is bounded (at most 2x max).
$total = $hot_count + $warm_count;
$this->assertLessThanOrEqual(
$max * 2,
$total,
"Total cache ({$total} entries) should not exceed 2x max (" . ( $max * 2 ) . ').'
);
}

Expand Down
Loading