Commit fd3341846f3 for php.net

commit fd3341846f32281f73125bc4fd2d320730d3feb2
Author: Tim Starling <tstarling@wikimedia.org>
Date:   Tue Jun 2 03:01:56 2026 +1000

    Add ZipArchive::closeString() (#21497)

    - Add a $flags parameter to ZipArchive::openString(), by analogy with
      ZipArchive::open(). This allows the string to be opened read/write.
    - Have the $data parameter to ZipArchive::openString() default to an
      empty string, for convenience of callers that want to create an empty
      archive. This works on all versions of libzip since the change in
      1.6.0 only applied to files, it's opt-in for generic sources.
    - Add ZipArchive::closeString() which closes the archive and returns
      the resulting string. For consistency with openString(), return an
      empty string if the archive is empty.

diff --git a/ext/zip/php_zip.c b/ext/zip/php_zip.c
index a8289a41a30..cb2389ca437 100644
--- a/ext/zip/php_zip.c
+++ b/ext/zip/php_zip.c
@@ -573,8 +573,12 @@ static char * php_zipobj_get_zip_comment(ze_zip_object *obj, int *len) /* {{{ */
 }
 /* }}} */

-/* Close and free the zip_t */
-static bool php_zipobj_close(ze_zip_object *obj) /* {{{ */
+/* Close and free the zip_t. If the archive was opened as a string, the
+ * final contents of the archive will be assigned to *out_str and that
+ * string will afterwards be owned by the caller.
+ *
+ * If out_str is NULL, the final string contents, if any, will be discarded. */
+static bool php_zipobj_close(ze_zip_object *obj, zend_string **out_str) /* {{{ */
 {
 	struct zip *intern = obj->za;
 	bool success = false;
@@ -606,7 +610,19 @@ static bool php_zipobj_close(ze_zip_object *obj) /* {{{ */
 		obj->filename_len = 0;
 	}

+	if (obj->out_str) {
+		if (out_str) {
+			*out_str = obj->out_str;
+		} else {
+			zend_string_release(obj->out_str);
+		}
+		obj->out_str = NULL;
+	} else {
+		ZEND_ASSERT(!out_str);
+	}
+
 	obj->za = NULL;
+	obj->from_string = false;
 	return success;
 }
 /* }}} */
@@ -1060,7 +1076,7 @@ static void php_zip_object_free_storage(zend_object *object) /* {{{ */
 {
 	ze_zip_object * intern = php_zip_fetch_object(object);

-	php_zipobj_close(intern);
+	php_zipobj_close(intern, NULL);

 #ifdef HAVE_PROGRESS_CALLBACK
 	/* if not properly called by libzip */
@@ -1467,7 +1483,7 @@ PHP_METHOD(ZipArchive, open)
 	}

 	/* If we already have an opened zip, free it */
-	php_zipobj_close(ze_obj);
+	php_zipobj_close(ze_obj, NULL);

 	/* open for write without option to empty the archive */
 	if ((flags & (ZIP_TRUNCATE | ZIP_RDONLY)) == 0) {
@@ -1491,28 +1507,34 @@ PHP_METHOD(ZipArchive, open)
 	ze_obj->filename = resolved_path;
 	ze_obj->filename_len = strlen(resolved_path);
 	ze_obj->za = intern;
+	ze_obj->from_string = false;
 	RETURN_TRUE;
 }
 /* }}} */

-/* {{{ Create new read-only zip using given string */
+/* {{{ Create new zip from a string, or a create an empty zip to be saved to a string */
 PHP_METHOD(ZipArchive, openString)
 {
-	zend_string *buffer;
+	zend_string *buffer = NULL;
+	zend_long flags = 0;
 	zval *self = ZEND_THIS;

-	if (zend_parse_parameters(ZEND_NUM_ARGS(), "S", &buffer) == FAILURE) {
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "|Sl", &buffer, &flags) == FAILURE) {
 		RETURN_THROWS();
 	}

+	if (!buffer) {
+		buffer = ZSTR_EMPTY_ALLOC();
+	}
+
 	ze_zip_object *ze_obj = Z_ZIP_P(self);

-	php_zipobj_close(ze_obj);
+	php_zipobj_close(ze_obj, NULL);

 	zip_error_t err;
 	zip_error_init(&err);

-	zip_source_t * zip_source = php_zip_create_string_source(buffer, NULL, &err);
+	zip_source_t * zip_source = php_zip_create_string_source(buffer, &ze_obj->out_str, &err);

 	if (!zip_source) {
 		ze_obj->err_zip = zip_error_code_zip(&err);
@@ -1521,7 +1543,7 @@ PHP_METHOD(ZipArchive, openString)
 		RETURN_LONG(ze_obj->err_zip);
 	}

-	struct zip *intern = zip_open_from_source(zip_source, ZIP_RDONLY, &err);
+	struct zip *intern = zip_open_from_source(zip_source, flags, &err);
 	if (!intern) {
 		ze_obj->err_zip = zip_error_code_zip(&err);
 		ze_obj->err_sys = zip_error_code_system(&err);
@@ -1530,6 +1552,7 @@ PHP_METHOD(ZipArchive, openString)
 		RETURN_LONG(ze_obj->err_zip);
 	}

+	ze_obj->from_string = true;
 	ze_obj->za = intern;
 	zip_error_fini(&err);
 	RETURN_TRUE;
@@ -1568,7 +1591,34 @@ PHP_METHOD(ZipArchive, close)

 	ZIP_FROM_OBJECT(intern, self);

-	RETURN_BOOL(php_zipobj_close(Z_ZIP_P(self)));
+	RETURN_BOOL(php_zipobj_close(Z_ZIP_P(self), NULL));
+}
+/* }}} */
+
+/* {{{ close the zip archive and get the result as a string */
+PHP_METHOD(ZipArchive, closeString)
+{
+	struct zip *intern;
+	zval *self = ZEND_THIS;
+
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	ZIP_FROM_OBJECT(intern, self);
+
+	if (!Z_ZIP_P(self)->from_string) {
+		zend_throw_error(NULL, "ZipArchive::closeString can only be called on "
+				"an archive opened with ZipArchive::openString");
+		RETURN_THROWS();
+	}
+
+	zend_string *ret = NULL;
+	bool success = php_zipobj_close(Z_ZIP_P(self), &ret);
+	ZEND_ASSERT(ret);
+	if (success) {
+		RETURN_STR(ret);
+	}
+	zend_string_release(ret);
+	RETURN_FALSE;
 }
 /* }}} */

diff --git a/ext/zip/php_zip.h b/ext/zip/php_zip.h
index 74c776ddb3d..9e57a54de1c 100644
--- a/ext/zip/php_zip.h
+++ b/ext/zip/php_zip.h
@@ -69,6 +69,8 @@ typedef struct _ze_zip_object {
 	HashTable *prop_handler;
 	char *filename;
 	size_t filename_len;
+	zend_string *out_str;
+	bool from_string;
 	zip_int64_t last_id;
 	int err_zip;
 	int err_sys;
diff --git a/ext/zip/php_zip.stub.php b/ext/zip/php_zip.stub.php
index 19ea67e07fb..49dd19e5355 100644
--- a/ext/zip/php_zip.stub.php
+++ b/ext/zip/php_zip.stub.php
@@ -646,7 +646,7 @@ class ZipArchive implements Countable
     /** @tentative-return-type */
     public function open(string $filename, int $flags = 0): bool|int {}

-    public function openString(string $data): bool|int {}
+    public function openString(string $data = '', int $flags = 0): bool|int {}

     /**
      * @tentative-return-type
@@ -656,6 +656,8 @@ public function setPassword(#[\SensitiveParameter] string $password): bool {}
     /** @tentative-return-type */
     public function close(): bool {}

+    public function closeString(): string|false {}
+
     /** @tentative-return-type */
     public function count(): int {}

diff --git a/ext/zip/php_zip_arginfo.h b/ext/zip/php_zip_arginfo.h
index ae2569400ef..faa6feb1cb1 100644
Binary files a/ext/zip/php_zip_arginfo.h and b/ext/zip/php_zip_arginfo.h differ
diff --git a/ext/zip/tests/ZipArchive_closeString_basic.phpt b/ext/zip/tests/ZipArchive_closeString_basic.phpt
new file mode 100644
index 00000000000..852a7ebf53f
--- /dev/null
+++ b/ext/zip/tests/ZipArchive_closeString_basic.phpt
@@ -0,0 +1,25 @@
+--TEST--
+ZipArchive::closeString() basic
+--EXTENSIONS--
+zip
+--FILE--
+<?php
+$zip = new ZipArchive();
+$zip->openString();
+$zip->addFromString('test1', '1');
+$zip->addFromString('test2', '2');
+$contents = $zip->closeString();
+echo $contents ? "OK\n" : "FAILED\n";
+
+$zip = new ZipArchive();
+$zip->openString($contents);
+var_dump($zip->getFromName('test1'));
+var_dump($zip->getFromName('test2'));
+var_dump($zip->getFromName('nonexistent'));
+
+?>
+--EXPECT--
+OK
+string(1) "1"
+string(1) "2"
+bool(false)
diff --git a/ext/zip/tests/ZipArchive_closeString_error.phpt b/ext/zip/tests/ZipArchive_closeString_error.phpt
new file mode 100644
index 00000000000..d5dca14a97f
--- /dev/null
+++ b/ext/zip/tests/ZipArchive_closeString_error.phpt
@@ -0,0 +1,47 @@
+--TEST--
+ZipArchive::closeString() error cases
+--EXTENSIONS--
+zip
+--FILE--
+<?php
+echo "1.\n";
+$zip = new ZipArchive();
+$zip->openString();
+var_dump($zip->open(__DIR__ . '/test.zip'));
+try {
+	$zip->closeString();
+} catch (Error $e) {
+	echo $e->getMessage() . "\n";
+}
+
+echo "2.\n";
+$zip = new ZipArchive();
+$zip->openString('...');
+echo $zip->getStatusString() . "\n";
+try {
+	$zip->closeString();
+} catch (Error $e) {
+	echo $e->getMessage() . "\n";
+}
+
+echo "3.\n";
+$zip = new ZipArchive();
+$zip->openString(file_get_contents(__DIR__ . '/test.zip'));
+echo gettype($zip->closeString()) . "\n";
+try {
+	$zip->closeString();
+} catch (Error $e) {
+	echo $e->getMessage() . "\n";
+}
+
+?>
+--EXPECT--
+1.
+bool(true)
+ZipArchive::closeString can only be called on an archive opened with ZipArchive::openString
+2.
+Not a zip archive
+Invalid or uninitialized Zip object
+3.
+string
+Invalid or uninitialized Zip object
diff --git a/ext/zip/tests/ZipArchive_closeString_false.phpt b/ext/zip/tests/ZipArchive_closeString_false.phpt
new file mode 100644
index 00000000000..8a7d942527f
--- /dev/null
+++ b/ext/zip/tests/ZipArchive_closeString_false.phpt
@@ -0,0 +1,22 @@
+--TEST--
+ZipArchive::closeString() false return
+--EXTENSIONS--
+zip
+--FILE--
+<?php
+$zip = new ZipArchive();
+// The "compressed size" fields are wrong, causing an error when reading the contents.
+// The error is reported on close when we rewrite the member with setCompressionIndex().
+// The error code is ER_DATA_LENGTH in libzip 1.10.0+ or ER_INCONS otherwise.
+$input = file_get_contents(__DIR__ . '/wrong-file-size.zip');
+var_dump($zip->openString($input));
+$zip->setCompressionIndex(0, ZipArchive::CM_DEFLATE);
+var_dump($zip->closeString());
+echo $zip->getStatusString() . "\n";
+?>
+--EXPECTREGEX--
+bool\(true\)
+
+Warning: ZipArchive::closeString\(\): (Zip archive inconsistent|Unexpected length of data).*
+bool\(false\)
+(Zip archive inconsistent|Unexpected length of data)
diff --git a/ext/zip/tests/ZipArchive_closeString_variation.phpt b/ext/zip/tests/ZipArchive_closeString_variation.phpt
new file mode 100644
index 00000000000..a1922ebd8f1
--- /dev/null
+++ b/ext/zip/tests/ZipArchive_closeString_variation.phpt
@@ -0,0 +1,39 @@
+--TEST--
+ZipArchive::closeString() variations
+--EXTENSIONS--
+zip
+--FILE--
+<?php
+echo "1. Empty archive creation\n";
+$zip = new ZipArchive();
+$zip->openString();
+var_dump($zip->closeString());
+echo $zip->getStatusString() . "\n";
+
+echo "2. Update existing archive\n";
+$input = file_get_contents(__DIR__ . '/test.zip');
+$zip = new ZipArchive();
+$zip->openString($input);
+$zip->addFromString('entry1.txt', '');
+$result = $zip->closeString();
+echo gettype($result) . "\n";
+var_dump($input !== $result);
+
+echo "3. Unchanged existing archive\n";
+$zip = new ZipArchive();
+$zip->openString($input);
+$result = $zip->closeString();
+echo gettype($result) . "\n";
+var_dump($input === $result);
+
+?>
+--EXPECT--
+1. Empty archive creation
+string(0) ""
+No error
+2. Update existing archive
+string
+bool(true)
+3. Unchanged existing archive
+string
+bool(true)
diff --git a/ext/zip/tests/ZipArchive_openString.phpt b/ext/zip/tests/ZipArchive_openString.phpt
index f787b4a8493..2a11505fcf6 100644
--- a/ext/zip/tests/ZipArchive_openString.phpt
+++ b/ext/zip/tests/ZipArchive_openString.phpt
@@ -4,8 +4,10 @@
 zip
 --FILE--
 <?php
+echo "1. Open read-only and read directory\n";
+$input = file_get_contents(__DIR__."/test_procedural.zip");
 $zip = new ZipArchive();
-$zip->openString(file_get_contents(__DIR__."/test_procedural.zip"));
+$zip->openString($input, ZipArchive::RDONLY);

 for ($i = 0; $i < $zip->numFiles; $i++) {
     $stat = $zip->statIndex($i);
@@ -17,8 +19,22 @@
 var_dump($zip->addEmptyDir("blub"));

 var_dump($zip->close());
+
+echo "2. CREATE and EXCL flags\n";
+$zip = new ZipArchive();
+var_dump($zip->openString($input, ZipArchive::CREATE));
+var_dump($zip->openString($input, ZipArchive::EXCL));
+echo $zip->getStatusString() . "\n";
+
+echo "3. CHECKCONS flag\n";
+$inconsistent = file_get_contents(__DIR__ . '/checkcons.zip');
+$zip = new ZipArchive();
+var_dump($zip->openString($inconsistent));
+var_dump($zip->openString($inconsistent, ZipArchive::CHECKCONS));
+
 ?>
 --EXPECTF--
+1. Open read-only and read directory
 foo
 bar
 foobar/
@@ -26,3 +42,10 @@
 bool(false)
 bool(false)
 bool(true)
+2. CREATE and EXCL flags
+bool(true)
+int(10)
+File already exists
+3. CHECKCONS flag
+bool(true)
+int(%d)
diff --git a/ext/zip/tests/checkcons.zip b/ext/zip/tests/checkcons.zip
new file mode 100644
index 00000000000..50bcea128a4
Binary files /dev/null and b/ext/zip/tests/checkcons.zip differ
diff --git a/ext/zip/tests/wrong-file-size.zip b/ext/zip/tests/wrong-file-size.zip
new file mode 100644
index 00000000000..fc9fa1a434c
Binary files /dev/null and b/ext/zip/tests/wrong-file-size.zip differ
diff --git a/ext/zip/zip_source.c b/ext/zip/zip_source.c
index 48b35862b90..03fa50867d5 100644
--- a/ext/zip/zip_source.c
+++ b/ext/zip/zip_source.c
@@ -155,6 +155,7 @@ static zip_int64_t php_zip_string_cb(void *userdata, void *data, zip_uint64_t le
 			ctx->in_str = ctx->out_str;
 			ctx->out_str = ZSTR_EMPTY_ALLOC();
 			if (ctx->dest) {
+				zend_string_release(*(ctx->dest));
 				*(ctx->dest) = zend_string_copy(ctx->in_str);
 			}
 			return 0;
@@ -200,5 +201,10 @@ zip_source_t * php_zip_create_string_source(zend_string *str, zend_string **dest
 	ctx->out_str = ZSTR_EMPTY_ALLOC();
 	ctx->dest = dest;
 	ctx->mtime = time(NULL);
+
+	if (dest) {
+		*dest = zend_string_copy(str);
+	}
+
 	return zip_source_function_create(php_zip_string_cb, (void*)ctx, err);
 }