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");