Commit f9b2ecfabbc for php.net

commit f9b2ecfabbcf80bd79fb812ee33eccfba9540ed8
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Sat Jun 20 07:53:54 2026 -0400

    ext/ftp: preserve bare CR bytes in ftp_get() ASCII mode (#22364)

    In ASCII mode ftp_get() stripped every '\r' and emitted only a following
    '\n', dropping bare CR bytes not part of a CRLF sequence. Fold CRLF to
    LF but write a lone '\r' through unchanged, carrying a '\r' on the final
    byte of a read into the next read and flushing it at EOF, so the buffer
    boundary behaves the same as the in-buffer case. ftp_nb_get() already
    does this via the lastch carry in ftp_nb_continue_read().

diff --git a/ext/ftp/ftp.c b/ext/ftp/ftp.c
index 1b1af31bb0c..900f26804b7 100644
--- a/ext/ftp/ftp.c
+++ b/ext/ftp/ftp.c
@@ -850,6 +850,7 @@ bool ftp_get(ftpbuf_t *ftp, php_stream *outstream, const char *path, const size_
 		goto bail;
 	}

+	bool pending_cr = false;
 	while ((rcvd = my_recv(ftp, data->fd, data->buf, FTP_BUFSIZE))) {
 		if (rcvd == (size_t)-1) {
 			goto bail;
@@ -869,13 +870,30 @@ bool ftp_get(ftpbuf_t *ftp, php_stream *outstream, const char *path, const size_
 			php_stream_write(outstream, ptr, (e - ptr));
 			ptr = e;
 #else
-			while (e > ptr && (s = memchr(ptr, '\r', (e - ptr)))) {
-				php_stream_write(outstream, ptr, (s - ptr));
-				if (s + 1 < e && *(s + 1) == '\n') {
-					s++;
+			if (pending_cr) {
+				pending_cr = false;
+				if (*ptr == '\n') {
 					php_stream_putc(outstream, '\n');
+					ptr++;
+				} else {
+					php_stream_putc(outstream, '\r');
+				}
+			}
+			while (e > ptr && (s = memchr(ptr, '\r', (e - ptr)))) {
+				if (s + 1 < e) {
+					if (*(s + 1) == '\n') {
+						php_stream_write(outstream, ptr, (s - ptr));
+						php_stream_putc(outstream, '\n');
+						ptr = s + 2;
+					} else {
+						php_stream_write(outstream, ptr, (s - ptr) + 1);
+						ptr = s + 1;
+					}
+				} else {
+					php_stream_write(outstream, ptr, (s - ptr));
+					pending_cr = true;
+					ptr = s + 1;
 				}
-				ptr = s + 1;
 			}
 #endif
 			if (ptr < e) {
@@ -885,6 +903,9 @@ bool ftp_get(ftpbuf_t *ftp, php_stream *outstream, const char *path, const size_
 			goto bail;
 		}
 	}
+	if (pending_cr) {
+		php_stream_putc(outstream, '\r');
+	}

 	data_close(ftp);

diff --git a/ext/ftp/tests/ftp_get_ascii_bare_cr.phpt b/ext/ftp/tests/ftp_get_ascii_bare_cr.phpt
new file mode 100644
index 00000000000..1fe92bf8de7
--- /dev/null
+++ b/ext/ftp/tests/ftp_get_ascii_bare_cr.phpt
@@ -0,0 +1,32 @@
+--TEST--
+ftp_get() ASCII mode: bare CR is preserved, CRLF folds to LF
+--EXTENSIONS--
+ftp
+pcntl
+--FILE--
+<?php
+require 'server.inc';
+
+$ftp = ftp_connect('127.0.0.1', $port);
+ftp_login($ftp, 'user', 'pass');
+$ftp or die("Couldn't connect to the server");
+
+$local = __DIR__ . "/bare_cr_out.txt";
+
+$expected = "line1\nba\rre\nend" . str_repeat("X", 4078) . "\r" . str_repeat("Y", 10);
+
+var_dump(ftp_get($ftp, $local, 'bare_cr', FTP_ASCII));
+var_dump(file_get_contents($local) === $expected);
+
+var_dump(ftp_get($ftp, $local, 'trailing_cr', FTP_ASCII));
+var_dump(file_get_contents($local) === "trail\r");
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . "/bare_cr_out.txt");
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/ftp/tests/server.inc b/ext/ftp/tests/server.inc
index 04e2ceefa27..251536ff820 100644
--- a/ext/ftp/tests/server.inc
+++ b/ext/ftp/tests/server.inc
@@ -398,6 +398,20 @@ if ($pid) {
                     fputs($fs, str_repeat("A", 4095) . "\r\n" . str_repeat("B", 10));
                     fputs($s, "226 Closing data Connection.\r\n");
                     break;
+                case "bare_cr":
+                    // A bare CR (not part of CRLF) mid-stream, plus a bare CR on
+                    // the final byte of the first FTP_BUFSIZE (4096) read followed
+                    // by a non-LF byte in the next read.
+                    fputs($s, "150 File status okay; about to open data connection.\r\n");
+                    fputs($fs, "line1\r\nba\rre\r\nend" . str_repeat("X", 4078) . "\r" . str_repeat("Y", 10));
+                    fputs($s, "226 Closing data Connection.\r\n");
+                    break;
+                case "trailing_cr":
+                    // The whole transfer ends on a bare CR.
+                    fputs($s, "150 File status okay; about to open data connection.\r\n");
+                    fputs($fs, "trail\r");
+                    fputs($s, "226 Closing data Connection.\r\n");
+                    break;

                 default:
                     fputs($s, "550 {$matches[1]}: No such file or directory \r\n");