diff --git a/apps/files_external/lib/Lib/Storage/SFTPReadStream.php b/apps/files_external/lib/Lib/Storage/SFTPReadStream.php index 7aaeac0040aa7..42dde1d778dc4 100644 --- a/apps/files_external/lib/Lib/Storage/SFTPReadStream.php +++ b/apps/files_external/lib/Lib/Storage/SFTPReadStream.php @@ -125,6 +125,8 @@ private function seekTo(int $offset): void { $this->internalPosition = $offset; $this->readPosition = $offset; $this->buffer = ''; + // a previous read may have reached the end of the file, reading from the new position has to work again + $this->eof = false; $this->request_chunk(256 * 1024); } diff --git a/apps/files_external/tests/Storage/SFTPReadStreamTest.php b/apps/files_external/tests/Storage/SFTPReadStreamTest.php new file mode 100644 index 0000000000000..52c3fc2dbaff6 --- /dev/null +++ b/apps/files_external/tests/Storage/SFTPReadStreamTest.php @@ -0,0 +1,118 @@ +packetType = null; + $state->requests = []; + + $sftp = $this->createMock(SFTP::class); + $sftp->method('realpath')->willReturnCallback(fn ($p) => $p); + + $stream = $this->getMockBuilder(SFTPReadStream::class) + ->onlyMethods(['invokeSftp', 'getSftpProperty']) + ->getMock(); + + $stream->method('getSftpProperty')->willReturnCallback(fn ($s, $prop) => match ($prop) { + 'bitmap' => SSH2::MASK_LOGIN, + 'packet_type' => $state->packetType, + default => null, + }); + + $stream->method('invokeSftp')->willReturnCallback(function ($s, $method, $args = []) use ($state, $content) { + switch ($method) { + case 'send_sftp_packet': + if ($args[0] === NET_SFTP_OPEN) { + $state->requests[] = ['type' => NET_SFTP_HANDLE, 'data' => pack('N', 6) . 'handle']; + } elseif ($args[0] === NET_SFTP_READ) { + $packet = $args[1]; + $handleLength = unpack('N', substr($packet, 0, 4))[1]; + [, $high, $low, $length] = unpack('N3', substr($packet, 4 + $handleLength)); + $offset = $high * (2 ** 32) + $low; + if ($offset >= strlen($content)) { + $state->requests[] = ['type' => NET_SFTP_STATUS, 'data' => pack('N', NET_SFTP_STATUS_EOF)]; + } else { + $state->requests[] = ['type' => NET_SFTP_DATA, 'data' => pack('N', 0) . substr($content, $offset, $length)]; + } + } + return null; + case 'get_sftp_packet': + $response = array_shift($state->requests); + $state->packetType = $response['type']; + return $response['data']; + case 'close_handle': + return true; + } + return null; + }); + + $stream->context = stream_context_create(['sftp' => ['session' => $sftp, 'size' => strlen($content)]]); + $opened = ''; + $this->assertTrue($stream->stream_open('sftpread://file.mp4', 'r', 0, $opened)); + return $stream; + } + + private function readAll(SFTPReadStream $stream): string { + $data = ''; + while (!$stream->stream_eof()) { + $data .= $stream->stream_read(8192); + } + return $data; + } + + public function testReadUntilEof(): void { + $stream = $this->openStream('0123456789'); + + $this->assertSame('0123456789', $this->readAll($stream)); + $this->assertTrue($stream->stream_eof()); + $this->assertSame(10, $stream->stream_tell()); + } + + public function testSeekReadsFromTheNewPosition(): void { + $stream = $this->openStream('0123456789'); + + $this->assertTrue($stream->stream_seek(4)); + $this->assertSame(4, $stream->stream_tell()); + $this->assertSame('456789', $this->readAll($stream)); + } + + public function testSeekBackAfterEof(): void { + $stream = $this->openStream('0123456789'); + $this->readAll($stream); + + // e.g. the movie preview reads the 'moov' atom at the end of a video and then copies the file from the start + $this->assertTrue($stream->stream_seek(0)); + $this->assertFalse($stream->stream_eof()); + $this->assertSame('0123456789', $this->readAll($stream)); + } +}