Commit dc7f6a8bebf for php.net

commit dc7f6a8bebfa7fd9608282524e071b2238903537
Author: Jorg Adam Sowa <jorg.sowa@gmail.com>
Date:   Thu Apr 9 14:37:14 2026 +0200

    ext/session: add recursive session cleanup for dirname in nested directories (#21491)

diff --git a/ext/session/mod_files.c b/ext/session/mod_files.c
index 60c6bfc1bd4..858cab879db 100644
--- a/ext/session/mod_files.c
+++ b/ext/session/mod_files.c
@@ -276,7 +276,10 @@ static zend_result ps_files_write(ps_files *data, zend_string *key, zend_string
 	return SUCCESS;
 }

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

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

 	while ((entry = readdir(dir))) {
-		/* does the file start with our prefix? */
-		if (!strncmp(entry->d_name, FILE_PREFIX, sizeof(FILE_PREFIX) - 1)) {
-			size_t entry_len = strlen(entry->d_name);
-
-			/* does it fit into our buffer? */
-			if (entry_len + ZSTR_LEN(dirname) + 2 < MAXPATHLEN) {
-				/* create the full path.. */
-				memcpy(buf + ZSTR_LEN(dirname) + 1, entry->d_name, entry_len);
-
-				/* NUL terminate it and */
-				buf[ZSTR_LEN(dirname) + entry_len + 1] = '\0';
-
-				/* check whether its last access was more than maxlifetime ago */
-				if (VCWD_STAT(buf, &sbuf) == 0 &&
-						(now - sbuf.st_mtime) > maxlifetime) {
-					VCWD_UNLINK(buf);
-					nrdels++;
+		/* skip . and .. */
+		if (entry->d_name[0] == '.' &&
+				(entry->d_name[1] == '\0' ||
+				 (entry->d_name[1] == '.' && entry->d_name[2] == '\0'))) {
+			continue;
+		}
+		size_t entry_len = strlen(entry->d_name);
+		/* does it fit into our buffer? */
+		if (ZSTR_LEN(dirname) + 1 + entry_len >= MAXPATHLEN) {
+			continue;
+		}
+		/* create the full path and NUL-terminate it */
+		memcpy(buf + ZSTR_LEN(dirname) + 1, entry->d_name, entry_len);
+		buf[ZSTR_LEN(dirname) + 1 + entry_len] = '\0';
+
+		if (remaining_depth == 0) {
+			/* target depth: delete expired session files */
+			if (strncmp(entry->d_name, FILE_PREFIX, sizeof(FILE_PREFIX) - 1) != 0) {
+				continue;
+			}
+			if (VCWD_STAT(buf, &sbuf) != 0) {
+				continue;
+			}
+			time(&now);
+			if ((now - sbuf.st_mtime) > maxlifetime) {
+				VCWD_UNLINK(buf);
+				nrdels++;
+			}
+		} else {
+			/* intermediate depth: recurse into subdirectories */
+			if (VCWD_STAT(buf, &sbuf) != 0) {
+				continue;
+			}
+			if (S_ISDIR(sbuf.st_mode)) {
+				zend_string *subdir = zend_string_init(buf, ZSTR_LEN(dirname) + 1 + entry_len, 0);
+				int n = ps_files_cleanup_dir(subdir, maxlifetime, remaining_depth - 1);
+				zend_string_release(subdir);
+				if (n >= 0) {
+					nrdels += n;
 				}
 			}
 		}
 	}

 	closedir(dir);
-
-	return (nrdels);
+	return nrdels;
 }

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

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

 	return *nrdels;
 }
diff --git a/ext/session/tests/mod_files/gc_dirdepth2.phpt b/ext/session/tests/mod_files/gc_dirdepth2.phpt
new file mode 100644
index 00000000000..a8724285125
--- /dev/null
+++ b/ext/session/tests/mod_files/gc_dirdepth2.phpt
@@ -0,0 +1,45 @@
+--TEST--
+session GC cleans expired sessions with save_path dirdepth=2 (two subdir levels)
+--EXTENSIONS--
+session
+--SKIPIF--
+<?php include(__DIR__ . '/../skipif.inc'); ?>
+--INI--
+session.gc_probability=0
+session.gc_maxlifetime=10
+--FILE--
+<?php
+$base = __DIR__ . '/gc_dirdepth2_test';
+@mkdir($base);
+@mkdir("$base/a");
+@mkdir("$base/a/b");
+
+session_save_path("2;$base");
+
+$stale_id   = 'abcdefghijklmnopqrstuvwx';
+$stale_file = "$base/a/b/sess_$stale_id";
+file_put_contents($stale_file, 'user|s:5:"alice";');
+touch($stale_file, time() - 100);
+
+session_id('ab000000000000000000000000');
+session_start();
+$result = session_gc();
+session_destroy();
+
+echo "session_gc() return value: ";
+var_dump($result);
+
+echo "expired file removed: ";
+var_dump(!file_exists($stale_file));
+?>
+--CLEAN--
+<?php
+$base = __DIR__ . '/gc_dirdepth2_test';
+@unlink("$base/a/b/sess_ab000000000000000000000000");
+@rmdir("$base/a/b");
+@rmdir("$base/a");
+@rmdir($base);
+?>
+--EXPECT--
+session_gc() return value: int(1)
+expired file removed: bool(true)
diff --git a/ext/session/tests/mod_files/gc_dirdepth_disabled.phpt b/ext/session/tests/mod_files/gc_dirdepth_disabled.phpt
new file mode 100644
index 00000000000..81c62430d15
--- /dev/null
+++ b/ext/session/tests/mod_files/gc_dirdepth_disabled.phpt
@@ -0,0 +1,68 @@
+--TEST--
+session GC correctly cleans expired sessions when save_path dirdepth > 0
+--EXTENSIONS--
+session
+--SKIPIF--
+<?php include(__DIR__ . '/../skipif.inc'); ?>
+--INI--
+session.gc_probability=0
+session.gc_maxlifetime=1
+--FILE--
+<?php
+
+$base = __DIR__ . '/gc_dirdepth_test';
+@mkdir($base);
+@mkdir("$base/a");
+
+// ── Part 1: dirdepth=1
+session_save_path("1;$base");
+
+$stale_id   = 'abcdefghijklmnopqrstuvwx';
+$stale_file = "$base/a/sess_$stale_id";
+file_put_contents($stale_file, 'user|s:5:"alice";');
+touch($stale_file, time() - 100);   // 100 s old; gc_maxlifetime=1 → must be GC'd
+
+session_id('a0000000000000000000000000');
+session_start();
+$result_depth = session_gc();
+session_destroy();
+$depth_file_gone = !file_exists($stale_file);
+
+// ── Part 2: dirdepth=0
+session_save_path($base);
+
+$flat_id   = 'bbcdefghijklmnopqrstuvwx';
+$flat_file = "$base/sess_$flat_id";
+file_put_contents($flat_file, 'user|s:5:"alice";');
+touch($flat_file, time() - 100);
+
+session_start();
+$result_flat = session_gc();
+session_destroy();
+$flat_file_gone = !file_exists($flat_file);
+
+echo "dirdepth=1 — session_gc() return value: ";
+var_dump($result_depth);
+
+echo "dirdepth=1 — expired session file removed: ";
+var_dump($depth_file_gone);
+
+echo "dirdepth=0 — session_gc() return value: ";
+var_dump($result_flat);
+
+echo "dirdepth=0 — expired session file removed: ";
+var_dump($flat_file_gone);
+?>
+--CLEAN--
+<?php
+$base = __DIR__ . '/gc_dirdepth_test';
+@unlink("$base/a/sess_abcdefghijklmnopqrstuvwx");
+@unlink("$base/a/sess_a0000000000000000000000000");
+@rmdir("$base/a");
+@rmdir($base);
+?>
+--EXPECT--
+dirdepth=1 — session_gc() return value: int(1)
+dirdepth=1 — expired session file removed: bool(true)
+dirdepth=0 — session_gc() return value: int(1)
+dirdepth=0 — expired session file removed: bool(true)
diff --git a/ext/session/tests/mod_files/gc_dirdepth_multi_subdir_count.phpt b/ext/session/tests/mod_files/gc_dirdepth_multi_subdir_count.phpt
new file mode 100644
index 00000000000..1ba047502f5
--- /dev/null
+++ b/ext/session/tests/mod_files/gc_dirdepth_multi_subdir_count.phpt
@@ -0,0 +1,54 @@
+--TEST--
+session GC accumulates correct total count across multiple subdirs, including empty ones (dirdepth=1)
+--EXTENSIONS--
+session
+--SKIPIF--
+<?php include(__DIR__ . '/../skipif.inc'); ?>
+--INI--
+session.gc_probability=0
+session.gc_maxlifetime=10
+--FILE--
+<?php
+$base = __DIR__ . '/gc_multi_subdir_test';
+@mkdir($base);
+@mkdir("$base/a");
+@mkdir("$base/b");
+@mkdir("$base/c");
+@mkdir("$base/d");   // empty subdir
+
+session_save_path("1;$base");
+
+$files = [
+    "$base/a/sess_aexpired0000000000000000",
+    "$base/b/sess_bexpired0000000000000000",
+    "$base/c/sess_cexpired0000000000000000",
+];
+foreach ($files as $f) {
+    file_put_contents($f, 'user|s:5:"alice";');
+    touch($f, time() - 100);
+}
+
+session_id('a0000000000000000000000000');
+session_start();
+$result = session_gc();
+session_destroy();
+
+echo "session_gc() return value: ";
+var_dump($result);
+
+echo "all expired files removed: ";
+var_dump(!file_exists($files[0]) && !file_exists($files[1]) && !file_exists($files[2]));
+?>
+--CLEAN--
+<?php
+$base = __DIR__ . '/gc_multi_subdir_test';
+@unlink("$base/a/sess_a0000000000000000000000000");
+@rmdir("$base/a");
+@rmdir("$base/b");
+@rmdir("$base/c");
+@rmdir("$base/d");
+@rmdir($base);
+?>
+--EXPECT--
+session_gc() return value: int(3)
+all expired files removed: bool(true)
diff --git a/ext/session/tests/mod_files/gc_dirdepth_selective.phpt b/ext/session/tests/mod_files/gc_dirdepth_selective.phpt
new file mode 100644
index 00000000000..a173324171e
--- /dev/null
+++ b/ext/session/tests/mod_files/gc_dirdepth_selective.phpt
@@ -0,0 +1,57 @@
+--TEST--
+session GC deletes only expired sess_* files and leaves all other files untouched (dirdepth=1)
+--EXTENSIONS--
+session
+--SKIPIF--
+<?php include(__DIR__ . '/../skipif.inc'); ?>
+--INI--
+session.gc_probability=0
+session.gc_maxlifetime=10
+--FILE--
+<?php
+$base = __DIR__ . '/gc_selective_test';
+@mkdir($base);
+@mkdir("$base/a");
+
+session_save_path("1;$base");
+
+$expired = "$base/a/sess_aexpired0000000000000000";
+$fresh   = "$base/a/sess_afresh000000000000000000";
+$other   = "$base/a/other_file";
+
+file_put_contents($expired, 'user|s:5:"alice";');
+touch($expired, time() - 100);   // 100 s old > gc_maxlifetime=10 → deleted
+
+file_put_contents($fresh, 'user|s:5:"alice";');
+touch($fresh, time() - 1);       // 1 s old < gc_maxlifetime=10 → kept
+
+file_put_contents($other, 'untouched');
+touch($other, time() - 100);     // old but no sess_ prefix → kept
+
+session_id('a0000000000000000000000000');  // first char 'a' → $base/a/
+session_start();
+$result = session_gc();   // int(1): exactly one deletion proves selectivity
+session_destroy();
+
+echo "session_gc() return value: ";
+var_dump($result);
+
+echo "expired sess_ file removed: ";
+var_dump(!file_exists($expired));
+
+echo "other file kept: ";
+var_dump(file_exists($other));
+?>
+--CLEAN--
+<?php
+$base = __DIR__ . '/gc_selective_test';
+@unlink("$base/a/sess_afresh000000000000000000");
+@unlink("$base/a/sess_a0000000000000000000000000");
+@unlink("$base/a/other_file");
+@rmdir("$base/a");
+@rmdir($base);
+?>
+--EXPECT--
+session_gc() return value: int(1)
+expired sess_ file removed: bool(true)
+other file kept: bool(true)
diff --git a/php.ini-development b/php.ini-development
index 6f93f440112..ee75459ea56 100644
--- a/php.ini-development
+++ b/php.ini-development
@@ -1386,13 +1386,6 @@ session.gc_divisor = 1000
 ; https://php.net/session.gc-maxlifetime
 session.gc_maxlifetime = 1440

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

 ; Check HTTP Referer to invalidate externally stored URLs containing ids.
 ; HTTP_REFERER has to contain this substring for the session to be
diff --git a/php.ini-production b/php.ini-production
index 9aafad21e9c..b10e2ba9944 100644
--- a/php.ini-production
+++ b/php.ini-production
@@ -1388,13 +1388,6 @@ session.gc_divisor = 1000
 ; https://php.net/session.gc-maxlifetime
 session.gc_maxlifetime = 1440

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

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