Skip to content

Commit dc7f6a8

Browse files
authored
ext/session: add recursive session cleanup for dirname in nested directories (php#21491)
1 parent da4185a commit dc7f6a8

File tree

7 files changed

+269
-45
lines changed

7 files changed

+269
-45
lines changed

ext/session/mod_files.c

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,10 @@ static zend_result ps_files_write(ps_files *data, zend_string *key, zend_string
276276
return SUCCESS;
277277
}
278278

279-
static int ps_files_cleanup_dir(const zend_string *dirname, zend_long maxlifetime)
279+
/* Recursively remove expired session files. When dirdepth > 0 the
280+
* cleanup descends into subdirectories up to that many levels before
281+
* inspecting individual session files. */
282+
static int ps_files_cleanup_dir(const zend_string *dirname, zend_long maxlifetime, size_t remaining_depth)
280283
{
281284
DIR *dir;
282285
struct dirent *entry;
@@ -291,8 +294,6 @@ static int ps_files_cleanup_dir(const zend_string *dirname, zend_long maxlifetim
291294
return -1;
292295
}
293296

294-
time(&now);
295-
296297
if (ZSTR_LEN(dirname) >= MAXPATHLEN) {
297298
php_error_docref(NULL, E_NOTICE, "ps_files_cleanup_dir: dirname(%s) is too long", ZSTR_VAL(dirname));
298299
closedir(dir);
@@ -304,31 +305,52 @@ static int ps_files_cleanup_dir(const zend_string *dirname, zend_long maxlifetim
304305
buf[ZSTR_LEN(dirname)] = PHP_DIR_SEPARATOR;
305306

306307
while ((entry = readdir(dir))) {
307-
/* does the file start with our prefix? */
308-
if (!strncmp(entry->d_name, FILE_PREFIX, sizeof(FILE_PREFIX) - 1)) {
309-
size_t entry_len = strlen(entry->d_name);
310-
311-
/* does it fit into our buffer? */
312-
if (entry_len + ZSTR_LEN(dirname) + 2 < MAXPATHLEN) {
313-
/* create the full path.. */
314-
memcpy(buf + ZSTR_LEN(dirname) + 1, entry->d_name, entry_len);
315-
316-
/* NUL terminate it and */
317-
buf[ZSTR_LEN(dirname) + entry_len + 1] = '\0';
318-
319-
/* check whether its last access was more than maxlifetime ago */
320-
if (VCWD_STAT(buf, &sbuf) == 0 &&
321-
(now - sbuf.st_mtime) > maxlifetime) {
322-
VCWD_UNLINK(buf);
323-
nrdels++;
308+
/* skip . and .. */
309+
if (entry->d_name[0] == '.' &&
310+
(entry->d_name[1] == '\0' ||
311+
(entry->d_name[1] == '.' && entry->d_name[2] == '\0'))) {
312+
continue;
313+
}
314+
size_t entry_len = strlen(entry->d_name);
315+
/* does it fit into our buffer? */
316+
if (ZSTR_LEN(dirname) + 1 + entry_len >= MAXPATHLEN) {
317+
continue;
318+
}
319+
/* create the full path and NUL-terminate it */
320+
memcpy(buf + ZSTR_LEN(dirname) + 1, entry->d_name, entry_len);
321+
buf[ZSTR_LEN(dirname) + 1 + entry_len] = '\0';
322+
323+
if (remaining_depth == 0) {
324+
/* target depth: delete expired session files */
325+
if (strncmp(entry->d_name, FILE_PREFIX, sizeof(FILE_PREFIX) - 1) != 0) {
326+
continue;
327+
}
328+
if (VCWD_STAT(buf, &sbuf) != 0) {
329+
continue;
330+
}
331+
time(&now);
332+
if ((now - sbuf.st_mtime) > maxlifetime) {
333+
VCWD_UNLINK(buf);
334+
nrdels++;
335+
}
336+
} else {
337+
/* intermediate depth: recurse into subdirectories */
338+
if (VCWD_STAT(buf, &sbuf) != 0) {
339+
continue;
340+
}
341+
if (S_ISDIR(sbuf.st_mode)) {
342+
zend_string *subdir = zend_string_init(buf, ZSTR_LEN(dirname) + 1 + entry_len, 0);
343+
int n = ps_files_cleanup_dir(subdir, maxlifetime, remaining_depth - 1);
344+
zend_string_release(subdir);
345+
if (n >= 0) {
346+
nrdels += n;
324347
}
325348
}
326349
}
327350
}
328351

329352
closedir(dir);
330-
331-
return (nrdels);
353+
return nrdels;
332354
}
333355

334356
static zend_result ps_files_key_exists(ps_files *data, const zend_string *key)
@@ -624,15 +646,7 @@ PS_GC_FUNC(files)
624646
{
625647
PS_FILES_DATA;
626648

627-
/* We don't perform any cleanup, if dirdepth is larger than 0.
628-
we return SUCCESS, since all cleanup should be handled by
629-
an external entity (i.e. find -ctime x | xargs rm) */
630-
631-
if (data->dirdepth == 0) {
632-
*nrdels = ps_files_cleanup_dir(data->basedir, maxlifetime);
633-
} else {
634-
*nrdels = -1; // Cannot process multiple depth save dir
635-
}
649+
*nrdels = ps_files_cleanup_dir(data->basedir, maxlifetime, data->dirdepth);
636650

637651
return *nrdels;
638652
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
--TEST--
2+
session GC cleans expired sessions with save_path dirdepth=2 (two subdir levels)
3+
--EXTENSIONS--
4+
session
5+
--SKIPIF--
6+
<?php include(__DIR__ . '/../skipif.inc'); ?>
7+
--INI--
8+
session.gc_probability=0
9+
session.gc_maxlifetime=10
10+
--FILE--
11+
<?php
12+
$base = __DIR__ . '/gc_dirdepth2_test';
13+
@mkdir($base);
14+
@mkdir("$base/a");
15+
@mkdir("$base/a/b");
16+
17+
session_save_path("2;$base");
18+
19+
$stale_id = 'abcdefghijklmnopqrstuvwx';
20+
$stale_file = "$base/a/b/sess_$stale_id";
21+
file_put_contents($stale_file, 'user|s:5:"alice";');
22+
touch($stale_file, time() - 100);
23+
24+
session_id('ab000000000000000000000000');
25+
session_start();
26+
$result = session_gc();
27+
session_destroy();
28+
29+
echo "session_gc() return value: ";
30+
var_dump($result);
31+
32+
echo "expired file removed: ";
33+
var_dump(!file_exists($stale_file));
34+
?>
35+
--CLEAN--
36+
<?php
37+
$base = __DIR__ . '/gc_dirdepth2_test';
38+
@unlink("$base/a/b/sess_ab000000000000000000000000");
39+
@rmdir("$base/a/b");
40+
@rmdir("$base/a");
41+
@rmdir($base);
42+
?>
43+
--EXPECT--
44+
session_gc() return value: int(1)
45+
expired file removed: bool(true)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
--TEST--
2+
session GC correctly cleans expired sessions when save_path dirdepth > 0
3+
--EXTENSIONS--
4+
session
5+
--SKIPIF--
6+
<?php include(__DIR__ . '/../skipif.inc'); ?>
7+
--INI--
8+
session.gc_probability=0
9+
session.gc_maxlifetime=1
10+
--FILE--
11+
<?php
12+
13+
$base = __DIR__ . '/gc_dirdepth_test';
14+
@mkdir($base);
15+
@mkdir("$base/a");
16+
17+
// ── Part 1: dirdepth=1
18+
session_save_path("1;$base");
19+
20+
$stale_id = 'abcdefghijklmnopqrstuvwx';
21+
$stale_file = "$base/a/sess_$stale_id";
22+
file_put_contents($stale_file, 'user|s:5:"alice";');
23+
touch($stale_file, time() - 100); // 100 s old; gc_maxlifetime=1 → must be GC'd
24+
25+
session_id('a0000000000000000000000000');
26+
session_start();
27+
$result_depth = session_gc();
28+
session_destroy();
29+
$depth_file_gone = !file_exists($stale_file);
30+
31+
// ── Part 2: dirdepth=0
32+
session_save_path($base);
33+
34+
$flat_id = 'bbcdefghijklmnopqrstuvwx';
35+
$flat_file = "$base/sess_$flat_id";
36+
file_put_contents($flat_file, 'user|s:5:"alice";');
37+
touch($flat_file, time() - 100);
38+
39+
session_start();
40+
$result_flat = session_gc();
41+
session_destroy();
42+
$flat_file_gone = !file_exists($flat_file);
43+
44+
echo "dirdepth=1 — session_gc() return value: ";
45+
var_dump($result_depth);
46+
47+
echo "dirdepth=1 — expired session file removed: ";
48+
var_dump($depth_file_gone);
49+
50+
echo "dirdepth=0 — session_gc() return value: ";
51+
var_dump($result_flat);
52+
53+
echo "dirdepth=0 — expired session file removed: ";
54+
var_dump($flat_file_gone);
55+
?>
56+
--CLEAN--
57+
<?php
58+
$base = __DIR__ . '/gc_dirdepth_test';
59+
@unlink("$base/a/sess_abcdefghijklmnopqrstuvwx");
60+
@unlink("$base/a/sess_a0000000000000000000000000");
61+
@rmdir("$base/a");
62+
@rmdir($base);
63+
?>
64+
--EXPECT--
65+
dirdepth=1 — session_gc() return value: int(1)
66+
dirdepth=1 — expired session file removed: bool(true)
67+
dirdepth=0 — session_gc() return value: int(1)
68+
dirdepth=0 — expired session file removed: bool(true)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
--TEST--
2+
session GC accumulates correct total count across multiple subdirs, including empty ones (dirdepth=1)
3+
--EXTENSIONS--
4+
session
5+
--SKIPIF--
6+
<?php include(__DIR__ . '/../skipif.inc'); ?>
7+
--INI--
8+
session.gc_probability=0
9+
session.gc_maxlifetime=10
10+
--FILE--
11+
<?php
12+
$base = __DIR__ . '/gc_multi_subdir_test';
13+
@mkdir($base);
14+
@mkdir("$base/a");
15+
@mkdir("$base/b");
16+
@mkdir("$base/c");
17+
@mkdir("$base/d"); // empty subdir
18+
19+
session_save_path("1;$base");
20+
21+
$files = [
22+
"$base/a/sess_aexpired0000000000000000",
23+
"$base/b/sess_bexpired0000000000000000",
24+
"$base/c/sess_cexpired0000000000000000",
25+
];
26+
foreach ($files as $f) {
27+
file_put_contents($f, 'user|s:5:"alice";');
28+
touch($f, time() - 100);
29+
}
30+
31+
session_id('a0000000000000000000000000');
32+
session_start();
33+
$result = session_gc();
34+
session_destroy();
35+
36+
echo "session_gc() return value: ";
37+
var_dump($result);
38+
39+
echo "all expired files removed: ";
40+
var_dump(!file_exists($files[0]) && !file_exists($files[1]) && !file_exists($files[2]));
41+
?>
42+
--CLEAN--
43+
<?php
44+
$base = __DIR__ . '/gc_multi_subdir_test';
45+
@unlink("$base/a/sess_a0000000000000000000000000");
46+
@rmdir("$base/a");
47+
@rmdir("$base/b");
48+
@rmdir("$base/c");
49+
@rmdir("$base/d");
50+
@rmdir($base);
51+
?>
52+
--EXPECT--
53+
session_gc() return value: int(3)
54+
all expired files removed: bool(true)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
--TEST--
2+
session GC deletes only expired sess_* files and leaves all other files untouched (dirdepth=1)
3+
--EXTENSIONS--
4+
session
5+
--SKIPIF--
6+
<?php include(__DIR__ . '/../skipif.inc'); ?>
7+
--INI--
8+
session.gc_probability=0
9+
session.gc_maxlifetime=10
10+
--FILE--
11+
<?php
12+
$base = __DIR__ . '/gc_selective_test';
13+
@mkdir($base);
14+
@mkdir("$base/a");
15+
16+
session_save_path("1;$base");
17+
18+
$expired = "$base/a/sess_aexpired0000000000000000";
19+
$fresh = "$base/a/sess_afresh000000000000000000";
20+
$other = "$base/a/other_file";
21+
22+
file_put_contents($expired, 'user|s:5:"alice";');
23+
touch($expired, time() - 100); // 100 s old > gc_maxlifetime=10 → deleted
24+
25+
file_put_contents($fresh, 'user|s:5:"alice";');
26+
touch($fresh, time() - 1); // 1 s old < gc_maxlifetime=10 → kept
27+
28+
file_put_contents($other, 'untouched');
29+
touch($other, time() - 100); // old but no sess_ prefix → kept
30+
31+
session_id('a0000000000000000000000000'); // first char 'a' → $base/a/
32+
session_start();
33+
$result = session_gc(); // int(1): exactly one deletion proves selectivity
34+
session_destroy();
35+
36+
echo "session_gc() return value: ";
37+
var_dump($result);
38+
39+
echo "expired sess_ file removed: ";
40+
var_dump(!file_exists($expired));
41+
42+
echo "other file kept: ";
43+
var_dump(file_exists($other));
44+
?>
45+
--CLEAN--
46+
<?php
47+
$base = __DIR__ . '/gc_selective_test';
48+
@unlink("$base/a/sess_afresh000000000000000000");
49+
@unlink("$base/a/sess_a0000000000000000000000000");
50+
@unlink("$base/a/other_file");
51+
@rmdir("$base/a");
52+
@rmdir($base);
53+
?>
54+
--EXPECT--
55+
session_gc() return value: int(1)
56+
expired sess_ file removed: bool(true)
57+
other file kept: bool(true)

php.ini-development

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,13 +1386,6 @@ session.gc_divisor = 1000
13861386
; https://php.net/session.gc-maxlifetime
13871387
session.gc_maxlifetime = 1440
13881388

1389-
; NOTE: If you are using the subdirectory option for storing session files
1390-
; (see session.save_path above), then garbage collection does *not*
1391-
; happen automatically. You will need to do your own garbage
1392-
; collection through a shell script, cron entry, or some other method.
1393-
; For example, the following script is the equivalent of setting
1394-
; session.gc_maxlifetime to 1440 (1440 seconds = 24 minutes):
1395-
; find /path/to/sessions -cmin +24 -type f | xargs rm
13961389

13971390
; Check HTTP Referer to invalidate externally stored URLs containing ids.
13981391
; HTTP_REFERER has to contain this substring for the session to be

php.ini-production

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,13 +1388,6 @@ session.gc_divisor = 1000
13881388
; https://php.net/session.gc-maxlifetime
13891389
session.gc_maxlifetime = 1440
13901390

1391-
; NOTE: If you are using the subdirectory option for storing session files
1392-
; (see session.save_path above), then garbage collection does *not*
1393-
; happen automatically. You will need to do your own garbage
1394-
; collection through a shell script, cron entry, or some other method.
1395-
; For example, the following script is the equivalent of setting
1396-
; session.gc_maxlifetime to 1440 (1440 seconds = 24 minutes):
1397-
; find /path/to/sessions -cmin +24 -type f | xargs rm
13981391

13991392
; Check HTTP Referer to invalidate externally stored URLs containing ids.
14001393
; HTTP_REFERER has to contain this substring for the session to be

0 commit comments

Comments
 (0)