Commit c07cc8502c0 for php.net

commit c07cc8502c0a5815fc8a182dde72500d397a8a86
Author: Jakub Zelenka <bukka@php.net>
Date:   Tue Sep 15 20:13:27 2026 +0200

    Fix bug #60110 (fclose(), file_put_contents(), copy() do not return false properly) (#12067)

    Propagate stream flush and close failures to the return value.

    Co-authored-by: Ilija Tovilo <ilija.tovilo@me.com>

diff --git a/NEWS b/NEWS
index 0a3e4cb674f..9ed869e6ae2 100644
--- a/NEWS
+++ b/NEWS
@@ -36,6 +36,10 @@ PHP                                                                        NEWS
   . Fixed bug GH-23385 (SplDoublyLinkedList::serialize() use-after-free when
     __serialize() removes an element). (David Carlier)

+- Standard:
+  . Fixed bug #60110 (fclose(), file_put_contents(), copy() do not return false
+    properly). (Jakub Zelenka, Ilija Tovilo)
+

 10 Sep 2026, PHP 8.6.0beta3

diff --git a/UPGRADING b/UPGRADING
index 0ebebd43e34..4836c26a287 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -733,6 +733,9 @@ PHP 8.6 UPGRADE NOTES
     directive when $details is true. It holds the built-in default value of the
     directive (or null if it has none), independent of values set in php.ini,
     on the command line, or at runtime.
+  . fclose(), file_put_contents() and copy() now return false when flushing
+    or closing the stream fails. Previously such failures were silently
+    ignored.

 - Zip:
   . zip_entry_close() return type has been narrowed from bool to true. The
diff --git a/ext/phar/stream.c b/ext/phar/stream.c
index 9474498e389..335f8a07342 100644
--- a/ext/phar/stream.c
+++ b/ext/phar/stream.c
@@ -488,7 +488,7 @@ static int phar_stream_flush(php_stream *stream) /* {{{ */
 		}
 		return ret;
 	} else {
-		return EOF;
+		return 0;
 	}
 }
 /* }}} */
diff --git a/ext/standard/file.c b/ext/standard/file.c
index b52e5ba9525..19e60a41b05 100644
--- a/ext/standard/file.c
+++ b/ext/standard/file.c
@@ -571,10 +571,10 @@ PHP_FUNCTION(file_put_contents)
 			numbytes = -1;
 			break;
 	}
-	php_stream_close(stream);
+	int close_result = php_stream_close(stream);
 	php_stream_error_operation_end(context);

-	if (numbytes < 0) {
+	if (numbytes < 0 || close_result) {
 		RETURN_FALSE;
 	}

@@ -776,12 +776,12 @@ PHPAPI PHP_FUNCTION(fclose)
 	}

 	php_stream_error_operation_begin();
-	php_stream_free(stream,
+	int free_result = php_stream_free(stream,
 		PHP_STREAM_FREE_KEEP_RSRC |
 		(stream->is_persistent ? PHP_STREAM_FREE_CLOSE_PERSISTENT : PHP_STREAM_FREE_CLOSE));
 	php_stream_error_operation_end_for_stream(stream);

-	RETURN_TRUE;
+	RETURN_BOOL(!free_result);
 }
 /* }}} */

@@ -1594,8 +1594,8 @@ PHPAPI zend_result php_copy_file_ctx(const char *src, const char *dest, int src_
 		ret = php_stream_copy_to_stream_ex(srcstream, deststream, PHP_STREAM_COPY_ALL, NULL);
 	}
 	php_stream_close(srcstream);
-	if (deststream) {
-		php_stream_close(deststream);
+	if (deststream && php_stream_close(deststream)) {
+		ret = FAILURE;
 	}
 	return ret;
 }
diff --git a/ext/standard/tests/file/bug60110.phpt b/ext/standard/tests/file/bug60110.phpt
new file mode 100644
index 00000000000..2bfb5a4a266
--- /dev/null
+++ b/ext/standard/tests/file/bug60110.phpt
@@ -0,0 +1,95 @@
+--TEST--
+Bug #60110 (fclose(), file_put_contents(), copy() do not return false properly)
+--FILE--
+<?php
+class astream
+{
+    public static $max = 100;
+
+    public $context;
+
+    protected $read = 0;
+
+    protected $flush = false;
+
+    function stream_open($path, $mode) {
+        $this->flush = basename($path) === 'flush';
+        return true;
+    }
+
+    function stream_write($data) {
+        var_dump($data);
+        return strlen($data);
+    }
+
+    function stream_read($length) {
+        if ($length > self::$max - $this->read) {
+            $length = self::$max  - $this->read;
+        }
+        $this->read += $length;
+        return str_repeat('a', $length);
+    }
+
+    function stream_tell() {
+        return $this->read;
+    }
+
+    function stream_eof() {
+        return $this->read == self::$max;
+    }
+
+    function stream_flush() {
+        return $this->flush;
+    }
+
+    function stream_stat() {
+        return fstat(fopen('php://memory', "r"));
+    }
+
+    function url_stat() {
+        return fstat(fopen('php://memory', "r"));
+    }
+}
+
+stream_wrapper_register('as', 'astream');
+
+$stream = fopen('as://flush', 'r+');
+var_dump(fwrite($stream, "data"));
+var_dump(fread($stream, 3));
+var_dump(fclose($stream));
+
+$stream = fopen('as://nothing', 'r+');
+var_dump(fwrite($stream, "data"));
+var_dump(fread($stream, 3));
+var_dump(fclose($stream));
+
+var_dump(file_put_contents('as://', 'test nothing'));
+var_dump(file_put_contents('as://flush', 'test flush'));
+
+$path = __DIR__ . '/bug60110_test_file.txt';
+var_dump(file_put_contents($path, 'sdata'));
+var_dump(copy($path, 'as://nothing'));
+var_dump(copy($path, 'as://flush'));
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/bug60110_test_file.txt');
+?>
+--EXPECT--
+string(4) "data"
+int(4)
+string(3) "aaa"
+bool(true)
+string(4) "data"
+int(4)
+string(3) "aaa"
+bool(false)
+string(12) "test nothing"
+bool(false)
+string(10) "test flush"
+int(10)
+int(5)
+string(5) "sdata"
+bool(false)
+string(5) "sdata"
+bool(true)
diff --git a/main/php_streams.h b/main/php_streams.h
index 8a8fa4b3a56..fb0c57ecf83 100644
--- a/main/php_streams.h
+++ b/main/php_streams.h
@@ -114,7 +114,9 @@ typedef struct _php_stream_ops  {
 	/* stdio like functions - these are mandatory! */
 	ssize_t (*write)(php_stream *stream, const char *buf, size_t count);
 	ssize_t (*read)(php_stream *stream, char *buf, size_t count);
+	/* returns 0 on success and non-zero on failure */
 	int    (*close)(php_stream *stream, int close_handle);
+	/* returns 0 on success (including nothing to flush) and non-zero on failure */
 	int    (*flush)(php_stream *stream);

 	const char *label; /* label for this ops structure */
diff --git a/main/streams/streams.c b/main/streams/streams.c
index a09a2180921..7cd63f0038d 100644
--- a/main/streams/streams.c
+++ b/main/streams/streams.c
@@ -335,9 +335,12 @@ fprintf(stderr, "stream_free: %s:%p[%s] preserve_handle=%d release_cast=%d remov
 		(close_options & PHP_STREAM_FREE_RSRC_DTOR) == 0);
 #endif

+	int flush_result;
 	if (stream->flags & PHP_STREAM_FLAG_WAS_WRITTEN || stream->writefilters.head) {
 		/* make sure everything is saved */
-		php_stream_flush_ex(stream, true);
+		flush_result = php_stream_flush_ex(stream, true);
+	} else {
+		flush_result = 0;
 	}

 	/* If not called from the resource dtor, remove the stream from the resource list. */
@@ -361,10 +364,17 @@ fprintf(stderr, "stream_free: %s:%p[%s] preserve_handle=%d release_cast=%d remov
 				Let's let the cookie code clean it all up.
 			 */
 			stream->in_free = 0;
-			return fclose(stream->stdiocast);
+			ret = fclose(stream->stdiocast);
+			if (!ret) {
+				ret = flush_result;
+			}
+			return ret;
 		}

 		ret = stream->ops->close(stream, preserve_handle ? 0 : 1);
+		if (!ret) {
+			ret = flush_result;
+		}
 		stream->abstract = NULL;

 		/* tidy up any FILE* that might have been fdopened */