Commit 2e0fa0a4444 for php.net

commit 2e0fa0a44441d74bf8cc4e1ce1c8af9cd4209f52
Author: Jakub Zelenka <bukka@php.net>
Date:   Sun May 3 19:26:31 2026 +0200

    Fix GHSA-vc5h-9ppw-p5f3: phar circular symlink crash

    Prevents infinite recursion in phar_get_link_source.

diff --git a/ext/phar/tests/tar/files/circular_symlinks.tar b/ext/phar/tests/tar/files/circular_symlinks.tar
new file mode 100644
index 00000000000..2af7bb8c238
Binary files /dev/null and b/ext/phar/tests/tar/files/circular_symlinks.tar differ
diff --git a/ext/phar/tests/tar/files/circular_symlinks_long.tar b/ext/phar/tests/tar/files/circular_symlinks_long.tar
new file mode 100644
index 00000000000..b2b4c1cb095
Binary files /dev/null and b/ext/phar/tests/tar/files/circular_symlinks_long.tar differ
diff --git a/ext/phar/tests/tar/files/circular_symlinks_rho.tar b/ext/phar/tests/tar/files/circular_symlinks_rho.tar
new file mode 100644
index 00000000000..2ccbf17c8c6
Binary files /dev/null and b/ext/phar/tests/tar/files/circular_symlinks_rho.tar differ
diff --git a/ext/phar/tests/tar/ghsa-vc5h-9ppw-p5f3-symlink-circular.phpt b/ext/phar/tests/tar/ghsa-vc5h-9ppw-p5f3-symlink-circular.phpt
new file mode 100644
index 00000000000..cf0048c0fcd
--- /dev/null
+++ b/ext/phar/tests/tar/ghsa-vc5h-9ppw-p5f3-symlink-circular.phpt
@@ -0,0 +1,27 @@
+--TEST--
+GHSA-vc5h-9ppw-p5f3 (circular symlinks in tar should not cause stack overflow)
+--CREDITS--
+Calvin Young - eWalker Consulting (HK) Limited
+Enoch Chow - Isomorph Cyber
+--EXTENSIONS--
+phar
+--FILE--
+<?php
+$base = dirname(__FILE__);
+
+// simple 2-cycle
+$phar = new PharData($base . '/files/circular_symlinks.tar');
+var_dump($phar['file_a']->getContent() === '');
+
+// rho-shaped cycle (tail leading into a loop)
+$phar = new PharData($base . '/files/circular_symlinks_rho.tar');
+var_dump($phar['file_a']->getContent() === '');
+
+// long cycle (400 entries)
+$phar = new PharData($base . '/files/circular_symlinks_long.tar');
+var_dump($phar['link_0']->getContent() === '');
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/phar/util.c b/ext/phar/util.c
index 352819bc934..e11d0ceb514 100644
--- a/ext/phar/util.c
+++ b/ext/phar/util.c
@@ -57,30 +57,59 @@ static char *phar_get_link_location(phar_entry_info *entry) /* {{{ */
 }
 /* }}} */

-phar_entry_info *phar_get_link_source(phar_entry_info *entry) /* {{{ */
+static phar_entry_info *phar_follow_one_link(phar_entry_info *entry)
 {
 	phar_entry_info *link_entry;
 	char *link;

-	if (!entry->link) {
-		return entry;
-	}
-
 	link = phar_get_link_location(entry);
 	if (NULL != (link_entry = zend_hash_str_find_ptr(&(entry->phar->manifest), entry->link, strlen(entry->link))) ||
 		NULL != (link_entry = zend_hash_str_find_ptr(&(entry->phar->manifest), link, strlen(link)))) {
 		if (link != entry->link) {
 			efree(link);
 		}
-		return phar_get_link_source(link_entry);
-	} else {
-		if (link != entry->link) {
-			efree(link);
+		return link_entry;
+	}
+
+	if (link != entry->link) {
+		efree(link);
+	}
+	return NULL;
+}
+
+phar_entry_info *phar_get_link_source(phar_entry_info *entry)
+{
+	phar_entry_info *slow, *fast;
+
+	if (!entry->link) {
+		return entry;
+	}
+
+	/*
+	 * Use Floyd's cycle detection algorithm to follow the symlink chain without unbounded
+	 * recursion. Each entry has at most one outgoing link, so if a cycle exists the fast pointer
+	 * will eventually meet the slow one. Otherwise the fast pointer reaches the end first.
+	 */
+	slow = fast = entry;
+	while (1) {
+		fast = phar_follow_one_link(fast);
+		if (!fast || !fast->link) {
+			return fast;
+		}
+		fast = phar_follow_one_link(fast);
+		if (!fast || !fast->link) {
+			return fast;
+		}
+
+		/* no need to check slow as it's always behind */
+		slow = phar_follow_one_link(slow);
+
+		if (slow == fast) {
+			/* circular symlink chain */
+			return NULL;
 		}
-		return NULL;
 	}
 }
-/* }}} */

 /* retrieve a phar_entry_info's current file pointer for reading contents */
 php_stream *phar_get_efp(phar_entry_info *entry, int follow_links) /* {{{ */