Skip to content

Commit 09042be

Browse files
authored
fix: filecache race condition (#637)
1 parent 023f41a commit 09042be

4 files changed

Lines changed: 131 additions & 20 deletions

File tree

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"phpseclib/phpseclib": "^3.0.35",
2727
"kelvinmo/simplejwt": "0.7.1",
2828
"webmozart/assert": "^1.11",
29-
"symfony/process": "^6.0||^7.0"
29+
"symfony/process": "^6.0||^7.0",
30+
"symfony/filesystem": "^6.3||^7.3"
3031
},
3132
"suggest": {
3233
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."

src/Cache/FileSystemCacheItemPool.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ public function __construct(string $path)
4646
return;
4747
}
4848

49-
if (!mkdir($this->cachePath)) {
49+
// Suppress the error for when the directory already exists because of a
50+
// race condition
51+
if (!@mkdir($this->cachePath, 0777, true) && !is_dir($this->cachePath)) {
5052
throw new ErrorException("Cache folder couldn't be created.");
5153
}
5254
}
@@ -111,7 +113,7 @@ public function save(CacheItemInterface $item): bool
111113
$itemPath = $this->cacheFilePath($item->getKey());
112114
$serializedItem = serialize($item->get());
113115

114-
$result = file_put_contents($itemPath, $serializedItem);
116+
$result = file_put_contents($itemPath, $serializedItem, LOCK_EX);
115117

116118
// 0 bytes write is considered a successful operation
117119
if ($result === false) {

tests/Cache/FileSystemCacheItemPoolTest.php

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
use Google\Auth\Cache\TypedItem;
2222
use PHPUnit\Framework\TestCase;
2323
use Psr\Cache\InvalidArgumentException;
24+
use Symfony\Component\Filesystem\Filesystem;
2425

2526
class FileSystemCacheItemPoolTest extends TestCase
2627
{
27-
private string $defaultCacheDirectory = '.cache';
28+
private string $cachePath;
29+
private Filesystem $filesystem;
2830
private FileSystemCacheItemPool $pool;
2931
private array $invalidChars = [
3032
'`', '~', '!', '@', '#', '$',
@@ -36,28 +38,21 @@ class FileSystemCacheItemPoolTest extends TestCase
3638

3739
public function setUp(): void
3840
{
39-
$this->pool = new FileSystemCacheItemPool($this->defaultCacheDirectory);
41+
$this->cachePath = sys_get_temp_dir() . '/google_auth_php_test/';
42+
$this->filesystem = new Filesystem();
43+
$this->filesystem->remove($this->cachePath);
44+
$this->pool = new FileSystemCacheItemPool($this->cachePath);
4045
}
4146

4247
public function tearDown(): void
4348
{
44-
$files = scandir($this->defaultCacheDirectory);
45-
46-
foreach ($files as $fileName) {
47-
if ($fileName === '.' || $fileName === '..') {
48-
continue;
49-
}
50-
51-
unlink($this->defaultCacheDirectory . '/' . $fileName);
52-
}
53-
54-
rmdir($this->defaultCacheDirectory);
49+
$this->filesystem->remove($this->cachePath);
5550
}
5651

5752
public function testInstanceCreatesCacheFolder()
5853
{
59-
$this->assertTrue(file_exists($this->defaultCacheDirectory));
60-
$this->assertTrue(is_dir($this->defaultCacheDirectory));
54+
$this->assertTrue(file_exists($this->cachePath));
55+
$this->assertTrue(is_dir($this->cachePath));
6156
}
6257

6358
public function testSaveAndGetItem()
@@ -134,10 +129,10 @@ public function testClear()
134129
{
135130
$item = $this->getNewItem();
136131
$this->pool->save($item);
137-
$this->assertLessThan(scandir($this->defaultCacheDirectory), 2);
132+
$this->assertLessThan(scandir($this->cachePath), 2);
138133
$this->pool->clear();
139134
// Clear removes all the files, but scandir returns `.` and `..` as files
140-
$this->assertEquals(count(scandir($this->defaultCacheDirectory)), 2);
135+
$this->assertEquals(count(scandir($this->cachePath)), 2);
141136
}
142137

143138
public function testSaveDeferredAndCommit()

tests/Cache/RaceConditionTest.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
/*
3+
* Copyright 2025 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
namespace Google\Auth\Tests\Cache;
19+
20+
use Google\Auth\Cache\FileSystemCacheItemPool;
21+
use Google\Auth\Cache\MemoryCacheItemPool;
22+
use Google\Auth\Cache\SysVCacheItemPool;
23+
use PHPUnit\Framework\TestCase;
24+
use Psr\Cache\CacheItemPoolInterface;
25+
use Symfony\Component\Filesystem\Filesystem;
26+
27+
class RaceConditionTest extends TestCase
28+
{
29+
private static string $cachePath;
30+
private static Filesystem $filesystem;
31+
32+
public static function setUpBeforeClass(): void
33+
{
34+
self::$cachePath = sys_get_temp_dir() . '/google_auth_php_test/';
35+
self::$filesystem = new Filesystem();
36+
self::$filesystem->remove(self::$cachePath);
37+
}
38+
39+
/**
40+
* @runInSeparateProcess
41+
* @dataProvider provideRaceCondition
42+
*/
43+
public function testRaceCondition(string $cacheClass)
44+
{
45+
if (!function_exists('pcntl_fork')) {
46+
$this->markTestSkipped('pcntl_fork is not available');
47+
}
48+
for ($i = 0; $i < 100; $i++) {
49+
50+
$pids = [];
51+
for ($j = 0; $j < 4; $j++) {
52+
$pid = pcntl_fork();
53+
if ($pid == -1) {
54+
$this->fail('Could not fork');
55+
}
56+
$pool = $this->createCacheItemPool($cacheClass);
57+
$item = $pool->getItem('foo');
58+
$item->set('bar');
59+
$pool->save($item);
60+
61+
if ($pid) {
62+
// parent
63+
$pids[] = $pid;
64+
} else {
65+
// child
66+
exit(0);
67+
}
68+
}
69+
70+
// parent
71+
$pool->save($item);
72+
73+
foreach ($pids as $pid) {
74+
pcntl_waitpid($pid, $status);
75+
$this->assertEquals(0, $status);
76+
}
77+
78+
$this->assertTrue($pool->hasItem('foo'));
79+
$cachedItem = $pool->getItem('foo');
80+
$this->assertEquals('bar', $cachedItem->get());
81+
}
82+
}
83+
84+
public function createCacheItemPool(string $cacheClass): CacheItemPoolInterface
85+
{
86+
switch ($cacheClass) {
87+
case FileSystemCacheItemPool::class:
88+
$cachePath = self::$cachePath . '/google_auth_php_test-' . rand();
89+
return new FileSystemCacheItemPool($cachePath);
90+
case MemoryCacheItemPool::class:
91+
return new MemoryCacheItemPool();
92+
case SysVCacheItemPool::class:
93+
return new SysVCacheItemPool();
94+
}
95+
96+
throw new \Exception('Unrecognized cache class: ' . $cacheClass);
97+
}
98+
99+
public function provideRaceCondition()
100+
{
101+
return [
102+
[FileSystemCacheItemPool::class],
103+
[MemoryCacheItemPool::class],
104+
[SysVCacheItemPool::class],
105+
];
106+
}
107+
108+
public static function tearDownAfterClass(): void
109+
{
110+
// remove all files generated from the filecaches
111+
self::$filesystem->remove(self::$cachePath);
112+
}
113+
}

0 commit comments

Comments
 (0)