diff --git a/CMakeLists.txt b/CMakeLists.txt index 0411fee..ab37502 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,7 @@ include(GNUInstallDirs) find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql Network) find_package(OpenSSL REQUIRED) find_package(ZLIB REQUIRED) +find_package(JPEG REQUIRED) qt_standard_project_setup() @@ -157,6 +158,7 @@ target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql Qt6::Network) target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm) target_link_libraries(orbithub PRIVATE OpenSSL::Crypto) target_link_libraries(orbithub PRIVATE ZLIB::ZLIB) +target_link_libraries(orbithub PRIVATE JPEG::JPEG) target_compile_definitions(orbithub PRIVATE ORBITHUB_VERSION_STRING="${PROJECT_VERSION}") if(TARGET freerdp AND TARGET winpr) target_compile_definitions(orbithub PRIVATE ORBITHUB_HAS_FREERDP) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 52c4be5..b5c6c0b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -121,25 +121,26 @@ Delivered: keyboard (Qt key -> X11 keysym mapping) and mouse/wheel input forwarding - `VncDisplayWidget` mirroring `RdpDisplayWidget`'s scale-to-fit rendering and input-forwarding shape -- 36 unit tests (`tests/test_vnc_session_backend.cpp`): pure-function +- 41 unit tests (`tests/test_vnc_session_backend.cpp`): pure-function coverage (DES key prep verified against an independently documented test vector, keysym mapping, socket-error mapping) plus state-machine coverage against a scripted in-process fake RFB server (all three protocol-version handshake shapes, auth success/failure, unsupported - security types, pixel-accurate Raw/Hextile/ZRLE decoding including a - ZRLE zlib-stream-persistence test across two separate - `FramebufferUpdate` messages, cursor/clipboard round trips) -- caught - and fixed a real re-entrancy bug (`abort()` synchronously re-firing - `disconnected()` mid-`failConnection()`, silently overwriting a specific - error with a generic one) + security types, pixel-accurate Raw/Hextile/ZRLE/Tight decoding including + a ZRLE zlib-stream-persistence test across two separate + `FramebufferUpdate` messages, a Tight stream-reset-flag test, cursor/ + clipboard round trips) -- caught and fixed a real re-entrancy bug + (`abort()` synchronously re-firing `disconnected()` mid- + `failConnection()`, silently overwriting a specific error with a + generic one) - Verified live against a real, independently implemented VNC server (TightVNC on Windows): connect with VNC Authentication, correct framebuffer dimensions and pixel data, clean disconnect, reconnect, live cursor-shape and clipboard-send checks. That particular server consistently chose Raw for actual framebuffer content regardless of - which compression encodings were announced, so Hextile/ZRLE's + which compression encodings were announced, so Hextile/ZRLE/Tight's real-world decode path isn't independently confirmed live -- the unit - tests are the primary correctness evidence for those two + tests are the primary correctness evidence for those - Per-tab VNC-only display mode toggle (tab-bar right-click -> `Display Mode`): `Scale to Fit` (default, matches RDP's behavior) or `Actual Size (Scrollbars)` -- renders the remote framebuffer at its @@ -158,22 +159,46 @@ Delivered: Unicode extension) in both directions - Remote cursor shape sync via RFB's Cursor pseudo-encoding, mirroring `RdpDisplayWidget`'s cursor handling in `VncDisplayWidget` -- Hextile and ZRLE compression encodings, in addition to Raw + CopyRect -- - meaningfully reduces bandwidth over slower links versus Raw alone. Pure - tile/pixel decode logic lives in `src/vnc_pixel_codecs.h/.cpp`, kept - separate from the wire-sequencing state machine so it's unit-testable - without a socket. ZRLE decoding links `ZLIB::ZLIB` (found via a fresh - top-level `find_package(ZLIB REQUIRED)`, independent of whether - vendored FreeRDP's own internal zlib usage stays enabled) +- Hextile, ZRLE, and Tight compression encodings, in addition to Raw + + CopyRect -- meaningfully reduces bandwidth over slower links versus Raw + alone; Tight in particular is the encoding most real VNC servers prefer + when the client offers it. Pure tile/pixel decode logic (including + Tight's three filters -- Copy, Palette, Gradient) lives in + `src/vnc_pixel_codecs.h/.cpp`, kept separate from the wire-sequencing + state machine so it's unit-testable without a socket. ZRLE and Tight's + Basic mode share the same "persistent zlib stream(s), decompress + in-memory, decode synchronously" approach (Tight has 4 independent + streams selected per-rectangle, individually reset via the + compression-control byte's low 4 bits). Tight's JPEG sub-mode decodes + via libjpeg-turbo directly (`find_package(JPEG REQUIRED)` -> + `JPEG::JPEG`), not `QImage`'s own JPEG plugin, to avoid a + packaging-dependent runtime failure mode. ZRLE/Tight link `ZLIB::ZLIB` + (found via a fresh top-level `find_package(ZLIB REQUIRED)`, independent + of whether vendored FreeRDP's own internal zlib usage stays enabled) +- 41 unit tests total, 15 of them for Hextile/ZRLE/Tight specifically, + including a ZRLE zlib-stream-persistence test across two separate + `FramebufferUpdate` messages and a Tight stream-reset-flag test proving + the low 4 control-byte bits actually tear down and reinitialize the + targeted stream rather than erroring out on stale state Known gaps (explicit scope decisions, not oversights -- see issue #3 for follow-up tracking): - Apple's Screen Sharing authentication (Diffie-Hellman + AES, security type 30) isn't implemented, so this can't yet reach macOS's built-in VNC server -- only standard VNC Authentication (type 2) and no-auth (type 1) -- Tight compression isn't implemented yet (Raw + CopyRect + Hextile + ZRLE - are); Tight typically compresses best of all of these, so bandwidth over - very slow links is still not as good as a full VNC client would achieve +- Tight's Basic compression mode always assumes zlib-compressed payloads; + the real protocol permits the server to skip compression for very small + (filtered byte count under ~12) payloads, which this decoder doesn't + special-case (the exact trigger/wire-signaling for that couldn't be + verified with confidence against the RFC text alone). In practice this + only affects rare, tiny rectangles -- solid or near-solid tiny areas are + virtually always sent as Fill instead -- and fails that one rectangle's + decode cleanly (disconnects with a clear error) rather than silently + misinterpreting it +- Tight's Gradient filter is implemented from RFC 6143's description but + is the least exercised/confirmed of the three filters against a real + server in this pass (most real-world Tight traffic uses Copy or + Palette) - No dynamic resize (connects at the server's native resolution; the `Scale to Fit`/`Actual Size` toggle changes how that fixed resolution is displayed locally, not what resolution is requested from the guest -- diff --git a/src/vnc_pixel_codecs.cpp b/src/vnc_pixel_codecs.cpp index ecb9e04..e2e1359 100644 --- a/src/vnc_pixel_codecs.cpp +++ b/src/vnc_pixel_codecs.cpp @@ -251,4 +251,119 @@ int decodeZrleTile(const QByteArray& data, int offset, int tileWidth, int tileHe return -1; } +QVector decodeTightCopyFilter(const QByteArray& data, int width, int height) +{ + const int pixelCount = width * height; + QVector pixels; + if (pixelCount <= 0 || data.size() < pixelCount * 3) { + return pixels; + } + pixels.reserve(pixelCount); + const auto* bytes = reinterpret_cast(data.constData()); + for (int i = 0; i < pixelCount; ++i) { + pixels.append(rgbFromPixelBytes(bytes + (i * 3))); + } + return pixels; +} + +QVector decodeTightPaletteFilter(const QByteArray& data, int width, int height) +{ + QVector pixels; + const int pixelCount = width * height; + if (pixelCount <= 0 || data.isEmpty()) { + return pixels; + } + + const auto* bytes = reinterpret_cast(data.constData()); + const int size = data.size(); + int pos = 0; + const int paletteSize = static_cast(bytes[pos]) + 1; // 1-256 colors + ++pos; + if (pos + (paletteSize * 3) > size) { + return pixels; + } + + QVector palette; + palette.reserve(paletteSize); + for (int i = 0; i < paletteSize; ++i) { + palette.append(rgbFromPixelBytes(bytes + pos)); + pos += 3; + } + + int bitsPerPixel = 8; + if (paletteSize <= 2) { + bitsPerPixel = 1; + } else if (paletteSize <= 4) { + bitsPerPixel = 2; + } else if (paletteSize <= 16) { + bitsPerPixel = 4; + } + + pixels.reserve(pixelCount); + int bitPos = 0; + for (int i = 0; i < pixelCount; ++i) { + const int byteIndex = pos + (bitPos / 8); + if (byteIndex >= size) { + return QVector(); + } + const int shift = 8 - (bitPos % 8) - bitsPerPixel; + const int mask = (1 << bitsPerPixel) - 1; + const int index = (bytes[byteIndex] >> shift) & mask; + if (index >= palette.size()) { + return QVector(); + } + pixels.append(palette.at(index)); + bitPos += bitsPerPixel; + } + return pixels; +} + +QVector decodeTightGradientFilter(const QByteArray& data, int width, int height) +{ + const int pixelCount = width * height; + QVector pixels; + if (pixelCount <= 0 || data.size() < pixelCount * 3) { + return pixels; + } + + const auto* bytes = reinterpret_cast(data.constData()); + auto predict = [](int left, int up, int upLeft) { + return qBound(0, left + up - upLeft, 255); + }; + + QVector rChan(pixelCount); + QVector gChan(pixelCount); + QVector bChan(pixelCount); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const int idx = (y * width) + x; + const uchar* px = bytes + (idx * 3); + // TPIXEL order matches rgbFromPixelBytes: B,G,R. + const int deltaB = px[0]; + const int deltaG = px[1]; + const int deltaR = px[2]; + + const int leftR = (x > 0) ? rChan[idx - 1] : 0; + const int leftG = (x > 0) ? gChan[idx - 1] : 0; + const int leftB = (x > 0) ? bChan[idx - 1] : 0; + const int upR = (y > 0) ? rChan[idx - width] : 0; + const int upG = (y > 0) ? gChan[idx - width] : 0; + const int upB = (y > 0) ? bChan[idx - width] : 0; + const int upLeftR = (x > 0 && y > 0) ? rChan[idx - width - 1] : 0; + const int upLeftG = (x > 0 && y > 0) ? gChan[idx - width - 1] : 0; + const int upLeftB = (x > 0 && y > 0) ? bChan[idx - width - 1] : 0; + + rChan[idx] = (predict(leftR, upR, upLeftR) + deltaR) & 0xFF; + gChan[idx] = (predict(leftG, upG, upLeftG) + deltaG) & 0xFF; + bChan[idx] = (predict(leftB, upB, upLeftB) + deltaB) & 0xFF; + } + } + + pixels.reserve(pixelCount); + for (int i = 0; i < pixelCount; ++i) { + pixels.append(qRgb(rChan.at(i), gChan.at(i), bChan.at(i))); + } + return pixels; +} + } diff --git a/src/vnc_pixel_codecs.h b/src/vnc_pixel_codecs.h index dbd9690..7abc619 100644 --- a/src/vnc_pixel_codecs.h +++ b/src/vnc_pixel_codecs.h @@ -74,6 +74,36 @@ QVector decodeHextileSubrects(bool coloured, int subrectCount, int decodeZrleTile(const QByteArray& data, int offset, int tileWidth, int tileHeight, QVector& pixels); +// Tight encoding (RFC 6143 SS7.7.4) filters. Unlike Hextile/ZRLE, a Tight +// rectangle is never internally tiled -- these operate on the whole +// rectangle's already-decompressed (or, for very small payloads the real +// protocol allows to skip compression entirely, raw -- NOT handled by this +// implementation, see VncSessionBackend's class comment) filtered byte +// stream at once. Each returns exactly width*height pixels on success; a +// short/malformed result (any size other than width*height, including an +// empty vector) signals truncated/invalid input to the caller. + +// "Copy" filter: `data` is exactly width*height TPIXELs (3 bytes each, +// row-major, same B,G,R order as rgbFromPixelBytes/ZRLE's CPIXEL). +QVector decodeTightCopyFilter(const QByteArray& data, int width, int height); + +// "Palette" filter: `data` is a 1-byte (paletteSize-1) count, then +// paletteSize TPIXELs, then a *continuous* (not row-padded, unlike ZRLE's +// packed palette) MSB-first bit-packed index stream covering width*height +// pixels, with bits-per-pixel derived from paletteSize the same way ZRLE's +// packed palette does (<=2 colors: 1 bit; <=4: 2 bits; <=16: 4 bits; +// otherwise 8 bits/1 byte per index, up to 256 colors). +QVector decodeTightPaletteFilter(const QByteArray& data, int width, int height); + +// "Gradient" filter: `data` is exactly width*height TPIXELs, each channel +// (R,G,B independently) carrying a delta from a predicted value computed +// from already-decoded neighbors (predicted = clamp(left + up - upleft, +// 0, 255); treated as 0 past the first row/column). This is the least +// commonly exercised of the three Tight filters in real-world traffic and +// the one this implementation has the lowest confidence in byte-for-byte +// -- flagged for extra scrutiny/testing. +QVector decodeTightGradientFilter(const QByteArray& data, int width, int height); + } #endif diff --git a/src/vnc_session_backend.cpp b/src/vnc_session_backend.cpp index 207ff3d..20f3d90 100644 --- a/src/vnc_session_backend.cpp +++ b/src/vnc_session_backend.cpp @@ -12,6 +12,10 @@ #include +extern "C" { +#include +} + #include #include #include @@ -38,14 +42,26 @@ constexpr qint32 kEncRaw = 0; constexpr qint32 kEncCopyRect = 1; constexpr qint32 kEncHextile = 5; constexpr qint32 kEncZRLE = 16; +constexpr qint32 kEncTight = 7; // RFC 6143 SS7.8.2 "Cursor pseudo-encoding": not a real screen-content // encoding -- a rectangle with this type carries a cursor shape update // instead (hotspot in x/y, image dims in width/height), never painted into // the framebuffer. constexpr qint32 kEncCursor = -239; -constexpr std::array kAnnouncedEncodings = { kEncRaw, kEncCopyRect, kEncHextile, - kEncZRLE, kEncCursor }; +constexpr std::array kAnnouncedEncodings = { kEncRaw, kEncCopyRect, kEncHextile, + kEncZRLE, kEncTight, kEncCursor }; + +// Tight compression-control byte, high nibble (after the low 4 bits' +// stream-reset flags are stripped off). +constexpr quint8 kTightModeFill = 0x08; +constexpr quint8 kTightModeJpeg = 0x09; +// Modes 0-7 are "Basic" compression; bits 0-1 of the mode select which of +// the 4 persistent zlib streams to use, bit 2 flags that an explicit +// filter-id byte follows (Copy is implied when it's clear). +constexpr quint8 kTightBasicModeMax = 0x07; +constexpr quint8 kTightBasicExplicitFilterFlag = 0x04; +constexpr quint8 kTightBasicStreamIndexMask = 0x03; constexpr int kZrleTileSize = 64; @@ -81,6 +97,62 @@ void appendU32BE(QByteArray& buf, quint32 value) buf.append(static_cast((value >> 8) & 0xFF)); buf.append(static_cast(value & 0xFF)); } + +// Decodes a Tight "JPEG" rectangle (a full baseline JPEG image covering +// the whole rectangle, no tiling) via libjpeg-turbo directly -- chosen +// over QImage::fromData(..., "JPEG") specifically to avoid depending on +// Qt's own JPEG plugin being present at runtime across every packaging +// target. Returns false on any decode failure or dimension mismatch +// rather than risk painting a misinterpreted image. +bool decodeJpegRectangle(const QByteArray& jpegBytes, int width, int height, QImage* outImage) +{ + if (jpegBytes.isEmpty() || width <= 0 || height <= 0) { + return false; + } + + jpeg_decompress_struct cinfo; + jpeg_error_mgr jerr; + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_decompress(&cinfo); + + jpeg_mem_src(&cinfo, reinterpret_cast(jpegBytes.constData()), + static_cast(jpegBytes.size())); + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + jpeg_destroy_decompress(&cinfo); + return false; + } + + cinfo.out_color_space = JCS_RGB; + jpeg_start_decompress(&cinfo); + + if (static_cast(cinfo.output_width) != width + || static_cast(cinfo.output_height) != height) { + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + return false; + } + + QImage image(width, height, QImage::Format_RGB32); + const int rowStride = static_cast(cinfo.output_width) * cinfo.output_components; + QVector rowBuffer(rowStride); + unsigned char* rowPointer[1]; + while (cinfo.output_scanline < cinfo.output_height) { + rowPointer[0] = rowBuffer.data(); + jpeg_read_scanlines(&cinfo, rowPointer, 1); + const int y = static_cast(cinfo.output_scanline) - 1; + auto* dst = reinterpret_cast(image.scanLine(y)); + for (int x = 0; x < width; ++x) { + const unsigned char* p = rowBuffer.constData() + (x * 3); + dst[x] = qRgb(p[0], p[1], p[2]); + } + } + + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + *outImage = image; + return true; +} } VncSessionBackend::VncSessionBackend(const Profile& profile, QObject* parent) @@ -106,9 +178,18 @@ VncSessionBackend::VncSessionBackend(const Profile& profile, QObject* parent) m_hextileSubrectsRemaining(0), m_hextileSubrectsColoured(false), m_zrleInflateStream(new z_stream_s()), - m_zrleInflateInitialized(false) + m_zrleInflateInitialized(false), + m_tightInflateStreams{ new z_stream_s(), new z_stream_s(), new z_stream_s(), + new z_stream_s() }, + m_tightInflateInitialized{ false, false, false, false }, + m_tightCompressionMode(0), + m_tightFilterId(0), + m_tightLengthByteIndex(0) { std::memset(m_zrleInflateStream, 0, sizeof(z_stream_s)); + for (z_stream_s* stream : m_tightInflateStreams) { + std::memset(stream, 0, sizeof(z_stream_s)); + } connect(m_socket, &QTcpSocket::connected, this, &VncSessionBackend::onSocketConnected); connect(m_socket, &QTcpSocket::readyRead, this, &VncSessionBackend::onSocketReadyRead); connect(m_socket, &QTcpSocket::disconnected, this, &VncSessionBackend::onSocketDisconnected); @@ -124,6 +205,12 @@ VncSessionBackend::~VncSessionBackend() inflateEnd(m_zrleInflateStream); } delete m_zrleInflateStream; + for (int i = 0; i < 4; ++i) { + if (m_tightInflateInitialized[i]) { + inflateEnd(m_tightInflateStreams[i]); + } + delete m_tightInflateStreams[i]; + } } void VncSessionBackend::connectSession(const SessionConnectOptions& options) @@ -361,6 +448,11 @@ void VncSessionBackend::onSocketDisconnected() && m_rfbState != RfbState::WaitingHextileRawTileData && m_rfbState != RfbState::WaitingZrleCompressedLength && m_rfbState != RfbState::WaitingZrleCompressedData + && m_rfbState != RfbState::WaitingTightCompressionControl + && m_rfbState != RfbState::WaitingTightFillColor + && m_rfbState != RfbState::WaitingTightFilterId + && m_rfbState != RfbState::WaitingTightLengthByte + && m_rfbState != RfbState::WaitingTightPayload && m_rfbState != RfbState::WaitingSetColourMapHeader && m_rfbState != RfbState::WaitingSetColourMapData && m_rfbState != RfbState::WaitingServerCutTextHeader @@ -419,6 +511,12 @@ void VncSessionBackend::resetProtocolState() inflateEnd(m_zrleInflateStream); m_zrleInflateInitialized = false; } + for (int i = 0; i < 4; ++i) { + if (m_tightInflateInitialized[i]) { + inflateEnd(m_tightInflateStreams[i]); + m_tightInflateInitialized[i] = false; + } + } } bool VncSessionBackend::haveBytes(int count) const @@ -588,6 +686,38 @@ void VncSessionBackend::advanceHextileTile() } } +bool VncSessionBackend::inflateTightStream(int streamIndex, const QByteArray& compressed, + QByteArray* decompressed) +{ + z_stream_s* stream = m_tightInflateStreams[streamIndex]; + if (!m_tightInflateInitialized[streamIndex]) { + std::memset(stream, 0, sizeof(z_stream_s)); + if (inflateInit(stream) != Z_OK) { + return false; + } + m_tightInflateInitialized[streamIndex] = true; + } + + constexpr int kChunkSize = 65536; + char outBuffer[kChunkSize]; + stream->next_in = reinterpret_cast(const_cast(compressed.constData())); + stream->avail_in = static_cast(compressed.size()); + int result = Z_OK; + while (stream->avail_in > 0) { + stream->next_out = reinterpret_cast(outBuffer); + stream->avail_out = kChunkSize; + result = inflate(stream, Z_NO_FLUSH); + const int produced = kChunkSize - static_cast(stream->avail_out); + if (produced > 0) { + decompressed->append(outBuffer, produced); + } + if (result != Z_OK) { + break; + } + } + return result == Z_OK || result == Z_STREAM_END; +} + void VncSessionBackend::processReceiveBuffer() { for (;;) { @@ -906,6 +1036,9 @@ void VncSessionBackend::processReceiveBuffer() case kEncZRLE: m_rfbState = RfbState::WaitingZrleCompressedLength; break; + case kEncTight: + m_rfbState = RfbState::WaitingTightCompressionControl; + break; default: { // SetEncodings (see kAnnouncedEncodings) is entirely // client-controlled, so a spec-compliant server will never @@ -1231,6 +1364,191 @@ void VncSessionBackend::processReceiveBuffer() break; } + case RfbState::WaitingTightCompressionControl: { + if (!haveBytes(1)) { + return; + } + const quint8 controlByte = static_cast(m_recvBuffer.at(0)); + m_recvBuffer.remove(0, 1); + + // Low 4 bits: reset flags for the 4 persistent Basic-mode zlib + // streams, independent of whatever mode this particular + // rectangle itself uses. + for (int i = 0; i < 4; ++i) { + if ((controlByte & (1 << i)) != 0 && m_tightInflateInitialized[i]) { + inflateEnd(m_tightInflateStreams[i]); + m_tightInflateInitialized[i] = false; + } + } + + const quint8 mode = controlByte >> 4; + m_tightCompressionMode = mode; + if (mode == kTightModeFill) { + m_rfbState = RfbState::WaitingTightFillColor; + } else if (mode == kTightModeJpeg) { + m_tightLengthByteIndex = 0; + m_pendingLength = 0; + m_rfbState = RfbState::WaitingTightLengthByte; + } else if (mode <= kTightBasicModeMax) { + if ((mode & kTightBasicExplicitFilterFlag) != 0) { + m_rfbState = RfbState::WaitingTightFilterId; + } else { + m_tightFilterId = 0; // Copy implied + m_tightLengthByteIndex = 0; + m_pendingLength = 0; + m_rfbState = RfbState::WaitingTightLengthByte; + } + } else { + failConnection( + QStringLiteral("The VNC server sent an invalid Tight compression-control byte."), + QStringLiteral("Control byte 0x%1").arg(controlByte, 2, 16, QChar('0'))); + return; + } + break; + } + + case RfbState::WaitingTightFillColor: { + if (!haveBytes(3)) { + return; + } + const QRgb color = VncPixelCodecs::rgbFromPixelBytes( + reinterpret_cast(m_recvBuffer.constData())); + m_recvBuffer.remove(0, 3); + + if (m_currentRectangle.width > 0 && m_currentRectangle.height > 0) { + QPainter painter(&m_framebuffer); + painter.fillRect(QRect(m_currentRectangle.x, m_currentRectangle.y, + m_currentRectangle.width, m_currentRectangle.height), + QColor::fromRgb(color)); + } + onRectangleFinished(); + break; + } + + case RfbState::WaitingTightFilterId: { + if (!haveBytes(1)) { + return; + } + m_tightFilterId = static_cast(m_recvBuffer.at(0)); + m_recvBuffer.remove(0, 1); + if (m_tightFilterId > 2) { + failConnection(QStringLiteral("The VNC server sent an unrecognized Tight filter id."), + QStringLiteral("Filter id %1").arg(m_tightFilterId)); + return; + } + m_tightLengthByteIndex = 0; + m_pendingLength = 0; + m_rfbState = RfbState::WaitingTightLengthByte; + break; + } + + case RfbState::WaitingTightLengthByte: { + if (!haveBytes(1)) { + return; + } + const quint8 b = static_cast(m_recvBuffer.at(0)); + m_recvBuffer.remove(0, 1); + + if (m_tightLengthByteIndex == 0) { + m_pendingLength = (b & 0x7F); + } else if (m_tightLengthByteIndex == 1) { + m_pendingLength |= static_cast(b & 0x7F) << 7; + } else { + m_pendingLength |= static_cast(b) << 14; + } + + // Compact-length encoding: up to 3 bytes, continuation-bit + // style (only the first two bytes' high bit can request + // another byte; the third byte's full 8 bits are always used + // as the final contribution, capping the value at ~4MB). + const bool continuation = (m_tightLengthByteIndex < 2) && ((b & 0x80) != 0); + ++m_tightLengthByteIndex; + if (continuation) { + break; + } + m_rfbState = RfbState::WaitingTightPayload; + break; + } + + case RfbState::WaitingTightPayload: { + if (!haveBytes(static_cast(m_pendingLength))) { + return; + } + const QByteArray payload = m_recvBuffer.left(static_cast(m_pendingLength)); + m_recvBuffer.remove(0, static_cast(m_pendingLength)); + + const int rectWidth = m_currentRectangle.width; + const int rectHeight = m_currentRectangle.height; + if (rectWidth <= 0 || rectHeight <= 0) { + onRectangleFinished(); + break; + } + + if (m_tightCompressionMode == kTightModeJpeg) { + QImage jpegImage; + if (!decodeJpegRectangle(payload, rectWidth, rectHeight, &jpegImage)) { + failConnection(QStringLiteral("The VNC server sent malformed Tight JPEG data."), + QStringLiteral("JPEG payload length %1").arg(payload.size())); + return; + } + { + QPainter painter(&m_framebuffer); + painter.drawImage(m_currentRectangle.x, m_currentRectangle.y, jpegImage); + } + onRectangleFinished(); + break; + } + + // Basic compression: always zlib-compressed by this decoder + // (the real protocol allows very small payloads to skip + // compression entirely; not handled here -- see class comment). + const int streamIndex = m_tightCompressionMode & kTightBasicStreamIndexMask; + QByteArray filtered; + if (!inflateTightStream(streamIndex, payload, &filtered)) { + failConnection( + QStringLiteral("The VNC server sent malformed Tight-compressed data."), + QStringLiteral("Stream %1, payload length %2").arg(streamIndex).arg(payload.size())); + return; + } + + QVector pixels; + switch (m_tightFilterId) { + case 0: + pixels = VncPixelCodecs::decodeTightCopyFilter(filtered, rectWidth, rectHeight); + break; + case 1: + pixels = VncPixelCodecs::decodeTightPaletteFilter(filtered, rectWidth, rectHeight); + break; + case 2: + pixels = VncPixelCodecs::decodeTightGradientFilter(filtered, rectWidth, rectHeight); + break; + default: + break; + } + if (pixels.size() != rectWidth * rectHeight) { + failConnection( + QStringLiteral("The VNC server sent a malformed Tight rectangle."), + QStringLiteral("Filter %1 produced %2 pixels, expected %3") + .arg(m_tightFilterId) + .arg(pixels.size()) + .arg(rectWidth * rectHeight)); + return; + } + + { + QImage rectImage(rectWidth, rectHeight, QImage::Format_RGB32); + for (int y = 0; y < rectHeight; ++y) { + for (int x = 0; x < rectWidth; ++x) { + rectImage.setPixel(x, y, pixels.at((y * rectWidth) + x)); + } + } + QPainter painter(&m_framebuffer); + painter.drawImage(m_currentRectangle.x, m_currentRectangle.y, rectImage); + } + onRectangleFinished(); + break; + } + case RfbState::WaitingSetColourMapHeader: { if (!haveBytes(5)) { return; diff --git a/src/vnc_session_backend.h b/src/vnc_session_backend.h index 4e36555..1a52c1e 100644 --- a/src/vnc_session_backend.h +++ b/src/vnc_session_backend.h @@ -9,6 +9,8 @@ #include #include +#include + class QTcpSocket; struct z_stream_s; @@ -24,9 +26,19 @@ struct z_stream_s; // Scope (see plan / issue #3 for the full rationale): standard VNC // Authentication (security type 2) and no-auth (type 1) only -- not // Apple's Screen Sharing scheme (type 30). Raw + CopyRect + Hextile + ZRLE -// encodings (Tight compression not yet implemented). No dynamic resize. -// Clipboard sync (Latin-1 only, per RFB's ServerCutText/ClientCutText) and -// remote cursor shape sync (the Cursor pseudo-encoding) are supported. +// + Tight encodings. No dynamic resize. Clipboard sync (Latin-1 only, per +// RFB's ServerCutText/ClientCutText) and remote cursor shape sync (the +// Cursor pseudo-encoding) are supported. +// +// Tight decoding gap: the real protocol allows the server to skip zlib +// compression entirely for very small Basic-mode payloads; this decoder +// always attempts to zlib-inflate them, so a server that takes that +// shortcut on a given rectangle would have that one rectangle fail rather +// than decode. This is intentionally not special-cased (the exact trigger +// condition/wire signaling for it could not be verified with confidence +// against the RFC text alone, and it only affects rare, tiny rectangles -- +// solid or near-solid tiny areas are virtually always sent as Fill instead +// in practice) -- see docs/PROGRESS.md. class VncSessionBackend : public SessionBackend { Q_OBJECT @@ -92,6 +104,11 @@ private: WaitingHextileRawTileData, WaitingZrleCompressedLength, WaitingZrleCompressedData, + WaitingTightCompressionControl, + WaitingTightFillColor, + WaitingTightFilterId, + WaitingTightLengthByte, + WaitingTightPayload, WaitingSetColourMapHeader, WaitingSetColourMapData, WaitingServerCutTextHeader, @@ -150,6 +167,17 @@ private: z_stream_s* m_zrleInflateStream; bool m_zrleInflateInitialized; + // Tight decode state (RFC 6143 SS7.7.4). Unlike ZRLE, Tight's "Basic" + // compression mode has 4 independent persistent zlib streams (chosen + // per-rectangle by 2 bits of the compression-control byte), each with + // its own lifecycle -- reset individually via the control byte's low 4 + // bits, otherwise persisting like ZRLE's single stream. + std::array m_tightInflateStreams; + std::array m_tightInflateInitialized; + quint8 m_tightCompressionMode; // compression-control byte >> 4 + quint8 m_tightFilterId; + int m_tightLengthByteIndex; + void setState(SessionState state, const QString& message); void resetProtocolState(); void processReceiveBuffer(); @@ -166,6 +194,7 @@ private: void sendClientCutText(const QString& text); QRect currentHextileTileRect() const; void advanceHextileTile(); + bool inflateTightStream(int streamIndex, const QByteArray& compressed, QByteArray* decompressed); }; #endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fbb7757..c4bc562 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,7 +34,7 @@ add_executable(test_vnc_session_backend ) target_include_directories(test_vnc_session_backend PRIVATE ${CMAKE_SOURCE_DIR}/src) target_link_libraries(test_vnc_session_backend PRIVATE - Qt6::Core Qt6::Gui Qt6::Network Qt6::Test OpenSSL::Crypto ZLIB::ZLIB + Qt6::Core Qt6::Gui Qt6::Network Qt6::Test OpenSSL::Crypto ZLIB::ZLIB JPEG::JPEG ) add_test(NAME test_vnc_session_backend COMMAND test_vnc_session_backend) diff --git a/tests/test_vnc_session_backend.cpp b/tests/test_vnc_session_backend.cpp index 0eb497d..1f0d317 100644 --- a/tests/test_vnc_session_backend.cpp +++ b/tests/test_vnc_session_backend.cpp @@ -6,6 +6,12 @@ #include +extern "C" { +#include +} + +#include + namespace { // Independently documented bit-reversal example for VNC Authentication's // DES key prep (password "COW"): 'C'=0x43, 'O'=0x4F, 'W'=0x57, each @@ -209,6 +215,88 @@ QByteArray zlibCompressChunk(z_stream& stream, const QByteArray& input, bool fin output.resize(totalOut); return output; } + +// Mirrors VncSessionBackend's compact-length decode in reverse, for +// building test fixtures. +void appendTightCompactLength(QByteArray& buf, quint32 length) +{ + buf.append(static_cast((length & 0x7F) | ((length > 0x7F) ? 0x80 : 0))); + if (length <= 0x7F) { + return; + } + length >>= 7; + buf.append(static_cast((length & 0x7F) | ((length > 0x7F) ? 0x80 : 0))); + if (length <= 0x7F) { + return; + } + length >>= 7; + buf.append(static_cast(length & 0xFF)); +} + +// A FramebufferUpdate message with exactly one Tight-encoded rectangle +// (RFC 6143 encoding type 7), given the already fully-formed byte stream +// to follow the rectangle header (starting with the compression-control +// byte). +QByteArray tightFramebufferUpdate(int x, int y, int width, int height, const QByteArray& tightData) +{ + QByteArray update; + update.append(char(0)); update.append(char(0)); + update.append(char(0)); update.append(char(1)); // 1 rectangle + appendU16(update, static_cast(x)); + appendU16(update, static_cast(y)); + appendU16(update, static_cast(width)); + appendU16(update, static_cast(height)); + update.append(char(0)); update.append(char(0)); update.append(char(0)); + update.append(char(7)); // encoding = Tight + update.append(tightData); + return update; +} + +// Encodes a solid-colored width x height baseline JPEG using libjpeg-turbo +// directly (mirroring the decode side's use of the same library), for +// exercising Tight's JPEG sub-mode against real, valid JPEG bytes. +QByteArray encodeJpegForTest(int width, int height, QRgb color) +{ + jpeg_compress_struct cinfo; + jpeg_error_mgr jerr; + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_compress(&cinfo); + + unsigned char* buffer = nullptr; + unsigned long bufferSize = 0; + jpeg_mem_dest(&cinfo, &buffer, &bufferSize); + + cinfo.image_width = static_cast(width); + cinfo.image_height = static_cast(height); + cinfo.input_components = 3; + cinfo.in_color_space = JCS_RGB; + jpeg_set_defaults(&cinfo); + jpeg_set_quality(&cinfo, 100, TRUE); + // Disable chroma subsampling so a solid color round-trips as exactly + // as JPEG's DCT quantization allows, for a tighter test tolerance. + for (int i = 0; i < cinfo.num_components; ++i) { + cinfo.comp_info[i].h_samp_factor = 1; + cinfo.comp_info[i].v_samp_factor = 1; + } + + jpeg_start_compress(&cinfo, TRUE); + QVector row(width * 3); + for (int x = 0; x < width; ++x) { + row[(x * 3) + 0] = static_cast(qRed(color)); + row[(x * 3) + 1] = static_cast(qGreen(color)); + row[(x * 3) + 2] = static_cast(qBlue(color)); + } + JSAMPROW rowPointer[1] = { row.data() }; + for (int y = 0; y < height; ++y) { + jpeg_write_scanlines(&cinfo, rowPointer, 1); + } + jpeg_finish_compress(&cinfo); + + const QByteArray result(reinterpret_cast(buffer), static_cast(bufferSize)); + jpeg_destroy_compress(&cinfo); + std::free(buffer); + return result; +} } class TestVncSessionBackend : public QObject @@ -255,6 +343,11 @@ private slots: void zrlePlainRleTileProducesExpectedPixels(); void zrlePaletteRleTileProducesExpectedPixels(); void zrleStreamPersistsAcrossTwoFramebufferUpdates(); + void tightFillProducesExpectedPixels(); + void tightBasicCopyFilterProducesExpectedPixels(); + void tightBasicPaletteFilterProducesExpectedPixels(); + void tightJpegProducesExpectedPixels(); + void tightStreamResetFlagAllowsIndependentDecoding(); private: std::unique_ptr m_server; @@ -1506,5 +1599,268 @@ void TestVncSessionBackend::zrleStreamPersistsAcrossTwoFramebufferUpdates() QCOMPARE(frames.at(1).pixelColor(1, 0), QColor(10, 20, 30)); } +void TestVncSessionBackend::tightFillProducesExpectedPixels() +{ + QByteArray tight; + tight.append(char(0x80)); // mode = Fill, no stream-reset bits + tight += cpixelBytes(10, 200, 30); + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(3, 3)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 3, 3, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(3, 3)); + for (int y = 0; y < 3; ++y) { + for (int x = 0; x < 3; ++x) { + QCOMPARE(m_lastFrame.pixelColor(x, y), QColor(10, 200, 30)); + } + } +} + +void TestVncSessionBackend::tightBasicCopyFilterProducesExpectedPixels() +{ + QByteArray filtered; + filtered += cpixelBytes(255, 0, 0); + filtered += cpixelBytes(0, 255, 0); + filtered += cpixelBytes(0, 0, 255); + filtered += cpixelBytes(255, 255, 255); + const QByteArray compressed = zlibCompressWhole(filtered); + + QByteArray tight; + tight.append(char(0x00)); // mode = Basic, stream 0, Copy implied + appendTightCompactLength(tight, static_cast(compressed.size())); + tight += compressed; + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(2, 2)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 2, 2, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(2, 2)); + QCOMPARE(m_lastFrame.pixelColor(0, 0), QColor(255, 0, 0)); + QCOMPARE(m_lastFrame.pixelColor(1, 0), QColor(0, 255, 0)); + QCOMPARE(m_lastFrame.pixelColor(0, 1), QColor(0, 0, 255)); + QCOMPARE(m_lastFrame.pixelColor(1, 1), QColor(255, 255, 255)); +} + +void TestVncSessionBackend::tightBasicPaletteFilterProducesExpectedPixels() +{ + // 4 pixels, 2-color palette, 1 bit/pixel, packed MSB-first: indices + // [0,1,1,0] -> byte 0b0110_0000 (same packing convention as ZRLE's + // packed palette; with only one row here, continuous vs row-padded + // bit-packing happen to coincide). + QByteArray filtered; + filtered.append(char(0x01)); // paletteSize - 1 = 1 -> paletteSize = 2 + filtered += cpixelBytes(10, 20, 30); // palette[0] + filtered += cpixelBytes(200, 210, 220); // palette[1] + filtered.append(char(0x60)); + const QByteArray compressed = zlibCompressWhole(filtered); + + QByteArray tight; + tight.append(char(0x40)); // mode = Basic, stream 0, explicit filter follows + tight.append(char(1)); // filter id = Palette + appendTightCompactLength(tight, static_cast(compressed.size())); + tight += compressed; + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(4, 1)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 4, 1, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(4, 1)); + QCOMPARE(m_lastFrame.pixelColor(0, 0), QColor(10, 20, 30)); + QCOMPARE(m_lastFrame.pixelColor(1, 0), QColor(200, 210, 220)); + QCOMPARE(m_lastFrame.pixelColor(2, 0), QColor(200, 210, 220)); + QCOMPARE(m_lastFrame.pixelColor(3, 0), QColor(10, 20, 30)); +} + +void TestVncSessionBackend::tightJpegProducesExpectedPixels() +{ + // 8x8 (one full JPEG MCU at 1x1 chroma sampling) solid red. + const QByteArray jpegBytes = encodeJpegForTest(8, 8, qRgb(220, 20, 20)); + + QByteArray tight; + tight.append(char(0x90)); // mode = JPEG + appendTightCompactLength(tight, static_cast(jpegBytes.size())); + tight += jpegBytes; + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(8, 8)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 8, 8, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(8, 8)); + // JPEG is lossy even at quality 100 -- assert closeness, not equality. + const QColor decoded = m_lastFrame.pixelColor(4, 4); + QVERIFY(qAbs(decoded.red() - 220) <= 8); + QVERIFY(qAbs(decoded.green() - 20) <= 8); + QVERIFY(qAbs(decoded.blue() - 20) <= 8); +} + +void TestVncSessionBackend::tightStreamResetFlagAllowsIndependentDecoding() +{ + QByteArray filteredA; + filteredA += cpixelBytes(10, 20, 30); + filteredA += cpixelBytes(10, 20, 30); + filteredA += cpixelBytes(10, 20, 30); + filteredA += cpixelBytes(10, 20, 30); + QByteArray filteredB; + filteredB += cpixelBytes(200, 210, 220); + filteredB += cpixelBytes(200, 210, 220); + filteredB += cpixelBytes(200, 210, 220); + filteredB += cpixelBytes(200, 210, 220); + + // Each independently a complete, self-contained zlib stream (its own + // deflateInit + Z_FINISH) -- decoding the second one correctly after + // the first, on the same persistent stream slot, requires that + // stream-reset bit 0 in rectangle B's control byte actually tore down + // and re-initialized stream 0 rather than trying to keep feeding an + // already-finished stream more data. + const QByteArray compressedA = zlibCompressWhole(filteredA); + const QByteArray compressedB = zlibCompressWhole(filteredB); + + QByteArray tightA; + tightA.append(char(0x00)); // mode = Basic, stream 0, Copy, no reset + appendTightCompactLength(tightA, static_cast(compressedA.size())); + tightA += compressedA; + + QByteArray tightB; + tightB.append(char(0x01)); // mode = Basic, stream 0, Copy, reset stream 0 + appendTightCompactLength(tightB, static_cast(compressedB.size())); + tightB += compressedB; + + QVector frames; + connect(m_backend.get(), &SessionBackend::frameUpdated, this, + [&frames](const QImage& frame) { frames.append(frame.copy()); }); + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tightA, tightB]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(2, 2)); + break; + case 3: // first (non-incremental) FramebufferUpdateRequest + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 2, 2, tightA)); + break; + case 4: // next (incremental) FramebufferUpdateRequest + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 2, 2, tightB)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(frames.size() >= 2); + QCOMPARE(m_lastState, SessionState::Connected); + QCOMPARE(frames.at(0).pixelColor(0, 0), QColor(10, 20, 30)); + QCOMPARE(frames.at(1).pixelColor(0, 0), QColor(200, 210, 220)); +} + QTEST_GUILESS_MAIN(TestVncSessionBackend) #include "test_vnc_session_backend.moc"