Commit c2b02f24dff for php

commit c2b02f24dffae346eef347db8a3323fbbe0f43fa
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Mon Aug 24 12:45:45 2026 -0400

    ext/session: Truncate session file after successful write

    Truncating before a shorter write discards the old session data even when
    the write fails without storing any bytes. Truncate only after the full
    write and update the cached size for subsequent writes.

    Closes GH-23653

diff --git a/NEWS b/NEWS
index 76faed7a4fe..531fb312e05 100644
--- a/NEWS
+++ b/NEWS
@@ -76,6 +76,10 @@ PHP                                                                        NEWS
   . Fixed use of uninitialized func in do_request() on OOM bailout.
     (David Carlier)

+- Session:
+  . Fixed session data loss in the files save handler when writing the session
+    file fails after it was already truncated. (Ilia Alshanetsky)
+
 - Standard:
   . Fixed chown() and lchown() failing to resolve user names in ZTS builds
     when getpwnam_r() needs a larger buffer. (Ilia Alshanetsky)
diff --git a/ext/session/mod_files.c b/ext/session/mod_files.c
index 74e77973405..bf495d815da 100644
--- a/ext/session/mod_files.c
+++ b/ext/session/mod_files.c
@@ -236,11 +236,6 @@ static zend_result ps_files_write(ps_files *data, zend_string *key, zend_string
 		return FAILURE;
 	}

-	/* Truncate file if the amount of new data is smaller than the existing data set. */
-	if (ZSTR_LEN(val) < data->st_size) {
-		php_ignore_value(ftruncate(data->fd, 0));
-	}
-
 #ifdef HAVE_PWRITE
 	n = pwrite(data->fd, ZSTR_VAL(val), ZSTR_LEN(val), 0);
 #else
@@ -274,6 +269,12 @@ static zend_result ps_files_write(ps_files *data, zend_string *key, zend_string
 		return FAILURE;
 	}

+	if (ZSTR_LEN(val) < data->st_size) {
+		php_ignore_value(ftruncate(data->fd, ZSTR_LEN(val)));
+	}
+
+	data->st_size = ZSTR_LEN(val);
+
 	return SUCCESS;
 }

diff --git a/ext/session/tests/session_files_repeated_write.phpt b/ext/session/tests/session_files_repeated_write.phpt
new file mode 100644
index 00000000000..d6f3446ba27
--- /dev/null
+++ b/ext/session/tests/session_files_repeated_write.phpt
@@ -0,0 +1,54 @@
+--TEST--
+Session files handler truncates repeated writes to the current data length
+--EXTENSIONS--
+session
+--INI--
+session.save_handler=files
+session.save_path={PWD}
+session.use_cookies=0
+session.cache_limiter=
+session.gc_probability=0
+--FILE--
+<?php
+$id = 'session-files-repeated-write';
+$file = __DIR__ . '/sess_' . $id;
+$handler = new SessionHandler();
+session_set_save_handler($handler);
+$values = ['longer data', 'short', 'SHORT', 'much longer data', ''];
+
+for ($count = 1; $count <= count($values); $count++) {
+    file_put_contents($file, '');
+    session_id($id);
+    session_start();
+    foreach (array_slice($values, 0, $count) as $value) {
+        var_dump($handler->write($id, $value));
+    }
+    session_abort();
+    var_dump(file_get_contents($file));
+}
+?>
+--CLEAN--
+<?php
+unlink(__DIR__ . '/sess_session-files-repeated-write');
+?>
+--EXPECT--
+bool(true)
+string(11) "longer data"
+bool(true)
+bool(true)
+string(5) "short"
+bool(true)
+bool(true)
+bool(true)
+string(5) "SHORT"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(16) "much longer data"
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(0) ""
diff --git a/ext/session/tests/session_write_failure_keeps_data.phpt b/ext/session/tests/session_write_failure_keeps_data.phpt
new file mode 100644
index 00000000000..7ef38fccea1
--- /dev/null
+++ b/ext/session/tests/session_write_failure_keeps_data.phpt
@@ -0,0 +1,59 @@
+--TEST--
+Session files handler must not truncate session file when write fails
+--EXTENSIONS--
+session
+posix
+pcntl
+--INI--
+error_reporting=E_ALL
+display_errors=1
+session.use_strict_mode=0
+session.save_handler=files
+session.serialize_handler=php
+--FILE--
+<?php
+$dir = __DIR__ . '/session_write_failure_keeps_data';
+mkdir($dir);
+ini_set('session.save_path', $dir);
+
+$sid = 'write-failure';
+$file = $dir . '/sess_' . $sid;
+
+ob_start();
+
+session_id($sid);
+session_start();
+$_SESSION['data'] = 'original data';
+session_write_close();
+
+$before = file_get_contents($file);
+echo "Original contents: ";
+var_dump($before);
+
+// A zero file size limit makes the next write fail before any byte lands;
+// SIGXFSZ has to be ignored or the process is killed instead.
+pcntl_signal(SIGXFSZ, SIG_IGN);
+echo "Set file size limit to zero: ";
+var_dump(posix_setrlimit(POSIX_RLIMIT_FSIZE, 0, POSIX_RLIMIT_INFINITY));
+
+session_start();
+$_SESSION['data'] = 'new';
+@session_write_close();
+
+echo "Contents unchanged after failed write: ";
+var_dump(file_get_contents($file) === $before);
+
+ob_end_flush();
+?>
+--CLEAN--
+<?php
+$dir = __DIR__ . '/session_write_failure_keeps_data';
+foreach (glob($dir . '/sess_*') as $f) {
+    unlink($f);
+}
+rmdir($dir);
+?>
+--EXPECT--
+Original contents: string(26) "data|s:13:"original data";"
+Set file size limit to zero: bool(true)
+Contents unchanged after failed write: bool(true)