diff --git a/docs/articles/eterm-kitty-graphics.md b/docs/articles/eterm-kitty-graphics.md index a70aee53e..ed1e77e9e 100644 --- a/docs/articles/eterm-kitty-graphics.md +++ b/docs/articles/eterm-kitty-graphics.md @@ -8,6 +8,7 @@ stream, and exclusively owns the OpenGL texture cache. ## Supported features - direct RGB and RGBA transmission, including chunking and zlib compression; +- POSIX shared-memory transmission, including byte offsets and explicit sizes; - PNG transmission; - image IDs, image numbers, placement IDs, source cropping, cell geometry, and pixel offsets; - root-image rectangle updates, animation frames, frame composition, and animation control; @@ -17,9 +18,9 @@ stream, and exclusively owns the OpenGL texture cache. - relative placements, including parent validation, cycle detection, and descendant movement; - terminal pixel-size queries and SGR pixel mouse coordinates. -Local file, temporary-file, and shared-memory transfer media are intentionally not enabled. Direct -transmission is the portable path and works through SSH without giving terminal applications access -to the terminal host's filesystem or shared-memory namespace. +Local file and temporary-file transfer media are intentionally not enabled. POSIX shared-memory +objects are opened read-only and immediately unlinked as required by the protocol. Direct +transmission remains the portable path and works through SSH. ## Resource and threading behavior diff --git a/include/eepp/system/base64.hpp b/include/eepp/system/base64.hpp index 1ebc7dd4c..b56c808d9 100644 --- a/include/eepp/system/base64.hpp +++ b/include/eepp/system/base64.hpp @@ -14,6 +14,7 @@ class EE_API Base64 { enum class DecodeMode { AllowWhitespace, NoWhitespace, + NoWhitespaceStrict, }; /** Encode binary data into base64 digits with MIME style === pads diff --git a/src/eepp/system/base64.cpp b/src/eepp/system/base64.cpp index 91ecf9d5b..e4cd6a3a6 100644 --- a/src/eepp/system/base64.cpp +++ b/src/eepp/system/base64.cpp @@ -1,5 +1,5 @@ -#include #include +#include namespace EE { namespace System { @@ -106,6 +106,46 @@ size_t decodeBase64( size_t in_len, const char* in, size_t out_len, unsigned cha return io; } +size_t decodeBase64Strict( size_t inLen, const char* input, size_t outLen, unsigned char* output ) { + size_t outputOffset = 0; + Uint32 value = 0; + unsigned bits = 0; + size_t padding = 0; + for ( size_t offset = 0; offset < inLen; ++offset ) { + const Uint8 decoded = base64dec_tab[static_cast( input[offset] )]; + if ( decoded == BASE64_PADDING ) { + padding = inLen - offset; + if ( padding > 2 || inLen % 4 != 0 ) + return static_cast( -1 ); + for ( size_t remainder = offset; remainder < inLen; ++remainder ) + if ( input[remainder] != '=' ) + return static_cast( -1 ); + break; + } + if ( decoded > 63 || padding != 0 ) + return static_cast( -1 ); + value = ( value << 6 ) | decoded; + bits += 6; + if ( bits >= 8 ) { + bits -= 8; + if ( outputOffset >= outLen ) + return static_cast( -1 ); + output[outputOffset++] = static_cast( ( value >> bits ) & 0xFF ); + if ( bits == 0 ) + value = 0; + } + } + const size_t dataLength = inLen - padding; + if ( dataLength % 4 == 1 || ( padding == 1 && dataLength % 4 != 3 ) || + ( padding == 2 && dataLength % 4 != 2 ) ) + return static_cast( -1 ); + // Reject non-canonical encodings whose unused bits are non-zero. Besides being strict, this + // avoids accepting multiple byte strings for the same payload at protocol boundaries. + if ( bits != 0 && ( value & ( ( 1u << bits ) - 1u ) ) != 0 ) + return static_cast( -1 ); + return outputOffset; +} + } // namespace size_t Base64::decode( size_t in_len, const char* in, size_t out_len, unsigned char* out ) { @@ -114,6 +154,8 @@ size_t Base64::decode( size_t in_len, const char* in, size_t out_len, unsigned c size_t Base64::decode( size_t in_len, const char* in, size_t out_len, unsigned char* out, DecodeMode mode ) { + if ( mode == DecodeMode::NoWhitespaceStrict ) + return decodeBase64Strict( in_len, in, out_len, out ); return mode == DecodeMode::NoWhitespace ? decodeBase64( in_len, in, out_len, out ) : decodeBase64( in_len, in, out_len, out ); } @@ -165,8 +207,8 @@ bool Base64::encode( std::string_view in, std::string& out ) { if ( out.size() < b64len ) out.resize( b64len ); - const size_t len = - encode( in.size(), reinterpret_cast( in.data() ), out.size(), out.data() ); + const size_t len = encode( in.size(), reinterpret_cast( in.data() ), + out.size(), out.data() ); if ( len != static_cast( -1 ) && len != out.size() ) out.resize( len ); diff --git a/src/modules/eterm/include/eterm/terminal/kittygraphicsprotocol.hpp b/src/modules/eterm/include/eterm/terminal/kittygraphicsprotocol.hpp index bd18bf7f6..16dc562f4 100644 --- a/src/modules/eterm/include/eterm/terminal/kittygraphicsprotocol.hpp +++ b/src/modules/eterm/include/eterm/terminal/kittygraphicsprotocol.hpp @@ -35,6 +35,7 @@ struct KittyGraphicsCommandData { std::string_view payload; std::optional format; std::optional dataSize; + std::optional dataOffset; std::optional more; std::optional imageId; std::optional imageNumber; @@ -167,7 +168,7 @@ class KittyGraphicsProtocol { Int32 gapMs{ 40 }; Uint32 usageHint{ 0 }; }; - std::vector rgba; + std::shared_ptr> rgba; std::unordered_map frames; Sizei size; Uint32 imageNumber{ 0 }; diff --git a/src/modules/eterm/src/eterm/terminal/kittygraphicsprotocol.cpp b/src/modules/eterm/src/eterm/terminal/kittygraphicsprotocol.cpp index 1e561b0f6..5f372a90c 100644 --- a/src/modules/eterm/src/eterm/terminal/kittygraphicsprotocol.cpp +++ b/src/modules/eterm/src/eterm/terminal/kittygraphicsprotocol.cpp @@ -9,8 +9,16 @@ #include #include #include +#include #include +#if EE_PLATFORM != EE_PLATFORM_WIN && EE_PLATFORM != EE_PLATFORM_EMSCRIPTEN +#include +#include +#include +#include +#endif + using namespace EE::System; namespace eterm { namespace Terminal { @@ -54,40 +62,96 @@ bool checkedPixelBytes( Uint32 width, Uint32 height, size_t channels, size_t& re return true; } -bool validBase64( std::string_view input, bool finalChunk ) { - if ( input.size() % 4 == 1 ) +bool decodeBase64( std::string_view input, bool finalChunk, std::vector& output ) { + if ( !finalChunk && input.size() % 4 != 0 ) + return false; + const size_t oldSize = output.size(); + const size_t capacity = Base64::decodeSafeOutLen( input.size() ); + constexpr size_t MaxTransferBytes = 64 * 1024 * 1024; + if ( oldSize > MaxTransferBytes || capacity > MaxTransferBytes - oldSize ) + return false; + output.resize( oldSize + capacity ); + const size_t decodedSize = + Base64::decode( input.size(), input.data(), capacity, output.data() + oldSize, + Base64::DecodeMode::NoWhitespaceStrict ); + if ( decodedSize == static_cast( -1 ) ) { + output.resize( oldSize ); return false; - size_t padding = 0; - for ( size_t i = 0; i < input.size(); ++i ) { - const unsigned char character = input[i]; - const bool alphabet = - ( character >= 'A' && character <= 'Z' ) || ( character >= 'a' && character <= 'z' ) || - ( character >= '0' && character <= '9' ) || character == '+' || character == '/'; - if ( character == '=' ) { - ++padding; - if ( !finalChunk || padding > 2 ) - return false; - } else if ( !alphabet || padding != 0 ) { - return false; - } } - return finalChunk || input.size() % 4 == 0; + output.resize( oldSize + decodedSize ); + return true; } -bool decodeBase64( std::string_view input, bool finalChunk, std::vector& output ) { - if ( !validBase64( input, finalChunk ) ) +bool readSharedMemory( const KittyGraphicsCommandData& data, std::vector& output ) { +#if EE_PLATFORM == EE_PLATFORM_WIN || EE_PLATFORM == EE_PLATFORM_EMSCRIPTEN + (void)data; + (void)output; + return false; +#else + std::vector decodedName; + if ( !decodeBase64( data.payload, true, decodedName ) || decodedName.empty() || + decodedName.size() > 255 || + std::find( decodedName.begin() + 1, decodedName.end(), '/' ) != decodedName.end() || + std::find( decodedName.begin(), decodedName.end(), 0 ) != decodedName.end() ) return false; - std::vector decoded( Base64::decodeSafeOutLen( input.size() ) ); - const size_t decodedSize = Base64::decode( input.size(), input.data(), decoded.size(), - decoded.data(), Base64::DecodeMode::NoWhitespace ); - if ( decodedSize == static_cast( -1 ) ) + std::string name( decodedName.begin(), decodedName.end() ); + const int descriptor = shm_open( name.c_str(), O_RDONLY, 0 ); + if ( descriptor == -1 ) return false; - decoded.resize( decodedSize ); - constexpr size_t MaxTransferBytes = 64 * 1024 * 1024; - if ( output.size() > MaxTransferBytes || decoded.size() > MaxTransferBytes - output.size() ) - return false; - output.insert( output.end(), decoded.begin(), decoded.end() ); - return true; + // POSIX Kitty transfers are single-use. Unlink immediately after opening so all error paths + // still retire the client-owned object while the descriptor keeps its contents alive. + shm_unlink( name.c_str() ); + struct stat status{}; + const size_t offset = data.dataOffset.value_or( 0 ); + bool valid = fstat( descriptor, &status ) == 0 && status.st_size >= 0 && + static_cast( status.st_size ) >= offset; + size_t bytes = 0; + if ( valid ) { + const size_t available = static_cast( status.st_size ) - offset; + bytes = data.dataSize.value_or( static_cast( + std::min( available, std::numeric_limits::max() ) ) ); + valid = bytes <= available && bytes <= 128 * 1024 * 1024; + } + if ( valid && bytes != 0 ) { + const size_t mappingBytes = offset + bytes; + valid = mappingBytes >= bytes && mappingBytes <= 128 * 1024 * 1024; + void* mapping = valid ? mmap( nullptr, mappingBytes, PROT_READ, MAP_SHARED, descriptor, 0 ) + : MAP_FAILED; + if ( mapping == MAP_FAILED ) { + valid = false; + } else { + const auto* source = static_cast( mapping ) + offset; + // mpv reuses the same shm name for every frame. If it reopened the object before + // we unlinked it above, its next memcpy can overlap this read. Require consecutive + // identical observations after yielding to the writer; otherwise retain the previous + // displayed frame instead of publishing visibly torn rows. + std::vector snapshot( source, source + bytes ); + bool stable = false; + unsigned stableObservations = 0; + constexpr unsigned RequiredStableObservations = 2; + constexpr unsigned MaxSnapshotAttempts = 6; + for ( unsigned attempt = 0; attempt < MaxSnapshotAttempts; ++attempt ) { + std::this_thread::yield(); + if ( std::memcmp( snapshot.data(), source, bytes ) == 0 ) { + if ( ++stableObservations == RequiredStableObservations ) { + stable = true; + break; + } + } else { + stableObservations = 0; + snapshot.assign( source, source + bytes ); + } + } + if ( stable ) + output = std::move( snapshot ); + else + valid = false; + munmap( mapping, mappingBytes ); + } + } + close( descriptor ); + return valid && bytes != 0; +#endif } bool placementContains( const TerminalVisiblePlacement& placement, Vector2i cell ) { @@ -147,6 +211,9 @@ KittyGraphicsParseResult KittyGraphicsProtocol::parse( std::string_view command case 'S': PARSE_UINT_FIELD( dataSize ); break; + case 'O': + PARSE_UINT_FIELD( dataOffset ); + break; case 'm': PARSE_UINT_FIELD( more ); valid = valid && unsignedValue <= 1; @@ -327,6 +394,20 @@ KittyGraphicsHandleResult KittyGraphicsProtocol::handleTransmit( const KittyGraphicsCommandData& data, bool display, bool query, bool frame, Vector2i cursor ) { const bool more = data.more.value_or( 0 ) != 0; + if ( data.transmission == 's' ) { + if ( mPending.active ) + mPending = {}; + PendingTransfer transfer; + transfer.data = data; + transfer.data.payload = {}; + transfer.display = display; + transfer.query = query; + transfer.frame = frame; + if ( !readSharedMemory( data, transfer.decodedData ) ) + return { response( data, KittyGraphicsError::DecodeFailed ), + KittyGraphicsError::DecodeFailed, false }; + return finishTransfer( std::move( transfer ), cursor ); + } if ( mPending.active ) { if ( frame != mPending.frame || data.format || data.dataSize || data.imageId || data.imageNumber || data.usageHint || data.placementId || data.width || data.height || @@ -361,6 +442,14 @@ KittyGraphicsProtocol::handleTransmit( const KittyGraphicsCommandData& data, boo transfer.query = query; transfer.frame = frame; transfer.active = true; + const Uint32 format = data.format.value_or( 32 ); + if ( format == 24 || format == 32 ) { + size_t expectedBytes = 0; + if ( data.width && data.height && + checkedPixelBytes( *data.width, *data.height, format == 24 ? 3 : 4, expectedBytes ) && + expectedBytes <= 64 * 1024 * 1024 ) + transfer.decodedData.reserve( expectedBytes ); + } if ( !decodeBase64( data.payload, !more, transfer.decodedData ) ) return { response( data, KittyGraphicsError::InvalidData ), KittyGraphicsError::InvalidData, false }; @@ -495,7 +584,9 @@ KittyGraphicsHandleResult KittyGraphicsProtocol::finishTransfer( PendingTransfer std::vector* destinationPixels = nullptr; bool createdFrame = false; if ( frameNumber == 1 ) { - destinationPixels = &image->second.rgba; + if ( !image->second.rgba.unique() ) + image->second.rgba = std::make_shared>( *image->second.rgba ); + destinationPixels = image->second.rgba.get(); } else { auto frame = image->second.frames.find( frameNumber ); if ( frame == image->second.frames.end() ) { @@ -505,7 +596,7 @@ KittyGraphicsHandleResult KittyGraphicsProtocol::finishTransfer( PendingTransfer if ( data.columns ) { const Uint32 baseFrame = *data.columns; if ( baseFrame == 1 ) - newFrame.rgba = image->second.rgba; + newFrame.rgba = *image->second.rgba; else { auto base = image->second.frames.find( baseFrame ); if ( base == image->second.frames.end() ) @@ -620,7 +711,7 @@ KittyGraphicsHandleResult KittyGraphicsProtocol::finishTransfer( PendingTransfer false }; auto existing = mImages.find( imageId ); - const size_t oldBytes = existing == mImages.end() ? 0 : existing->second.rgba.size(); + const size_t oldBytes = existing == mImages.end() ? 0 : existing->second.rgba->size(); if ( !ensureCapacity( pixels.size(), imageId, existing == mImages.end() ) ) return { response( data, KittyGraphicsError::NoSpace ), KittyGraphicsError::NoSpace, false }; @@ -646,8 +737,8 @@ KittyGraphicsHandleResult KittyGraphicsProtocol::finishTransfer( PendingTransfer image.usageHint = data.usageHint.value_or( 0 ); image.anonymous = anonymous; image.creationSerial = ++mCreationSerial; - image.rgba = std::move( pixels ); - mStorageBytes = mStorageBytes - oldBytes + image.rgba.size(); + image.rgba = std::make_shared>( std::move( pixels ) ); + mStorageBytes = mStorageBytes - oldBytes + image.rgba->size(); const bool replaced = existing != mImages.end(); auto inserted = mImages.insert_or_assign( imageId, std::move( image ) ).first; @@ -658,7 +749,7 @@ KittyGraphicsHandleResult KittyGraphicsProtocol::finishTransfer( PendingTransfer update.imageSize = inserted->second.size; update.region = Rect( 0, 0, inserted->second.size.getWidth(), inserted->second.size.getHeight() ); - update.rgba = std::make_shared>( inserted->second.rgba ); + update.rgba = inserted->second.rgba; mUpdates.emplace_back( std::move( update ) ); ++mStats.fullImageUpdates; ++mPresentationGeneration; @@ -1122,7 +1213,7 @@ KittyGraphicsProtocol::composeFrames( const KittyGraphicsCommandData& data ) { false }; auto pixelsFor = [&]( Uint32 frameNumber ) -> std::vector* { if ( frameNumber == 1 ) - return &image->second.rgba; + return image->second.rgba.get(); auto frame = image->second.frames.find( frameNumber ); return frame == image->second.frames.end() ? nullptr : &frame->second.rgba; }; @@ -1162,6 +1253,10 @@ KittyGraphicsProtocol::composeFrames( const KittyGraphicsCommandData& data ) { source->data() + static_cast( sourceY + row ) * imageStride + static_cast( sourceX ) * 4, rowBytes ); + if ( destinationFrame == 1 && !image->second.rgba.unique() ) { + image->second.rgba = std::make_shared>( *image->second.rgba ); + destination = image->second.rgba.get(); + } const bool replace = data.cursorMovement.value_or( 0 ) == 1; for ( Uint32 row = 0; row < height; ++row ) { Uint8* target = destination->data() + @@ -1239,7 +1334,7 @@ void KittyGraphicsProtocol::eraseImage( KittyImageId imageId ) { auto image = mImages.find( imageId ); if ( image == mImages.end() ) return; - mStorageBytes -= image->second.rgba.size(); + mStorageBytes -= image->second.rgba->size(); for ( const auto& frame : image->second.frames ) mFrameStorageBytes -= frame.second.rgba.size(); mImages.erase( image ); @@ -1252,7 +1347,7 @@ void KittyGraphicsProtocol::eraseImage( KittyImageId imageId ) { bool KittyGraphicsProtocol::ensureCapacity( size_t bytes, KittyImageId replacingId, bool addingImage ) { auto replaced = mImages.find( replacingId ); - const size_t replacedBytes = replaced == mImages.end() ? 0 : replaced->second.rgba.size(); + const size_t replacedBytes = replaced == mImages.end() ? 0 : replaced->second.rgba->size(); auto hasCapacity = [&] { return bytes <= mMaxStorageBytes && mStorageBytes - replacedBytes <= mMaxStorageBytes - bytes && @@ -1401,7 +1496,7 @@ std::shared_ptr KittyGraphicsProtocol::takePresent const std::vector* KittyGraphicsProtocol::imagePixels( KittyImageId imageId ) const { auto image = mImages.find( imageId ); - return image == mImages.end() ? nullptr : &image->second.rgba; + return image == mImages.end() ? nullptr : image->second.rgba.get(); } bool KittyGraphicsProtocol::hasVirtualPlacements() const { @@ -1539,7 +1634,7 @@ void KittyGraphicsProtocol::resync() { create.imageId = image.first; create.imageSize = image.second.size; create.region = Rect( 0, 0, image.second.size.getWidth(), image.second.size.getHeight() ); - create.rgba = std::make_shared>( image.second.rgba ); + create.rgba = image.second.rgba; mUpdates.emplace_back( std::move( create ) ); for ( const auto& frame : image.second.frames ) { TerminalGraphicsUpdate createFrame; diff --git a/src/modules/eterm/src/eterm/terminal/kittygraphicsrenderer.cpp b/src/modules/eterm/src/eterm/terminal/kittygraphicsrenderer.cpp index d9d91735f..7b8302d33 100644 --- a/src/modules/eterm/src/eterm/terminal/kittygraphicsrenderer.cpp +++ b/src/modules/eterm/src/eterm/terminal/kittygraphicsrenderer.cpp @@ -26,8 +26,22 @@ bool KittyGraphicsRenderer::applyUpdates( std::vector&& return false; switch ( update.type ) { - case TerminalGraphicsUpdateType::CreateImage: case TerminalGraphicsUpdateType::ReplaceImage: { + if ( !update.rgba || update.imageSize.getWidth() <= 0 || + update.imageSize.getHeight() <= 0 ) + return false; + auto existing = mImages.find( update.imageId ); + if ( existing != mImages.end() && existing->second.texture && + existing->second.size == update.imageSize ) { + existing->second.texture->update( update.rgba->data(), + update.imageSize.getWidth(), + update.imageSize.getHeight(), 0, 0 ); + existing->second.frames.clear(); + break; + } + [[fallthrough]]; + } + case TerminalGraphicsUpdateType::CreateImage: { if ( !update.rgba || update.imageSize.getWidth() <= 0 || update.imageSize.getHeight() <= 0 ) return false; diff --git a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp index ad0295896..18c03c529 100644 --- a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp @@ -2664,10 +2664,16 @@ void TerminalEmulator::strdump( void ) { } void TerminalEmulator::strreset( void ) { - auto old = mStrescseq.buf; + char* buffer = mStrescseq.buf; + size_t capacity = mStrescseq.siz; + constexpr size_t MaxRetainedStringCapacity = 128 * 1024; + if ( !buffer || capacity > MaxRetainedStringCapacity ) { + buffer = (char*)xrealloc( buffer, STR_BUF_SIZ ); + capacity = STR_BUF_SIZ; + } mStrescseq = STREscape{}; - mStrescseq.buf = (char*)xrealloc( old, STR_BUF_SIZ ); - mStrescseq.siz = STR_BUF_SIZ; + mStrescseq.buf = buffer; + mStrescseq.siz = capacity; } void TerminalEmulator::sendbreak( const TerminalArg* ) { @@ -3230,6 +3236,45 @@ int TerminalEmulator::twrite( const char* buf, int buflen, int show_ctrl ) { int n; for ( n = 0; n < buflen; n += charsize ) { + /* Kitty control data and payload are ASCII transport bytes. Once ESC _ G has been + * recognized, append ordinary bytes in bulk instead of routing every Base64 byte through + * UTF-8 decoding and the terminal character state machine. Control bytes remain on the + * normal path so fragmented ESC \\ termination and malformed strings retain their exact + * behavior. */ + if ( !show_ctrl && ( mTerm.esc & ESC_STR ) && mStrescseq.type == '_' && + mStrescseq.len > 0 && mStrescseq.buf[0] == 'G' ) { + int end = n; + while ( end < buflen ) { + const unsigned char byte = static_cast( buf[end] ); + if ( byte == '\a' || byte == 030 || byte == 032 || byte == 033 || + ( byte >= 0x80 && byte <= 0x9F ) ) + break; + ++end; + } + const size_t bytes = static_cast( end - n ); + if ( bytes != 0 ) { + if ( !mStrescseq.discarded ) { + if ( mStrescseq.len > MAX_KITTY_GRAPHICS_APC_SIZE || + bytes > MAX_KITTY_GRAPHICS_APC_SIZE - mStrescseq.len ) { + mStrescseq.discarded = true; + } else { + const size_t required = mStrescseq.len + bytes + 1; + if ( required > mStrescseq.siz ) { + size_t capacity = mStrescseq.siz; + while ( capacity < required ) + capacity = eemin( capacity * 2, MAX_KITTY_GRAPHICS_APC_SIZE + 1 ); + mStrescseq.buf = (char*)xrealloc( mStrescseq.buf, capacity ); + mStrescseq.siz = capacity; + } + std::memcpy( mStrescseq.buf + mStrescseq.len, buf + n, bytes ); + mStrescseq.len += bytes; + } + } + n = end; + if ( n == buflen ) + return buflen; + } + } if ( IS_SET( MODE_UTF8 ) ) { /* process a complete utf8 char */ charsize = utf8decode( buf + n, &u, buflen - n ); diff --git a/src/tests/unit_tests/eterm_tests.cpp b/src/tests/unit_tests/eterm_tests.cpp index d5987e2a4..7936bd146 100644 --- a/src/tests/unit_tests/eterm_tests.cpp +++ b/src/tests/unit_tests/eterm_tests.cpp @@ -1,6 +1,7 @@ #include "utest.hpp" #include #include +#include #include #include #include @@ -13,6 +14,12 @@ #include #include +#if EE_PLATFORM != EE_PLATFORM_WIN && EE_PLATFORM != EE_PLATFORM_EMSCRIPTEN +#include +#include +#include +#endif + using namespace eterm::Terminal; using namespace eterm::System; using namespace EE::System; @@ -23,6 +30,7 @@ class MockPty : public IPseudoTerminal { std::string mWrites; bool mLoopWrites{ true }; size_t mMaxRead{ std::numeric_limits::max() }; + std::deque mReadSizes; size_t mReadOffset{ 0 }; std::atomic mBytesRead{ 0 }; int mCols = 80; @@ -48,7 +56,12 @@ class MockPty : public IPseudoTerminal { int read( char* buf, size_t n, bool ) override { if ( mReadOffset == mBuffer.size() ) return 0; - size_t toRead = std::min( { n, mBuffer.size() - mReadOffset, mMaxRead } ); + size_t readLimit = mMaxRead; + if ( !mReadSizes.empty() ) { + readLimit = mReadSizes.front(); + mReadSizes.pop_front(); + } + size_t toRead = std::min( { n, mBuffer.size() - mReadOffset, readLimit } ); memcpy( buf, mBuffer.data() + mReadOffset, toRead ); mReadOffset += toRead; mBytesRead.fetch_add( toRead, std::memory_order_relaxed ); @@ -935,6 +948,62 @@ UTEST( eterm, kitty_graphics_apc_is_fragmentation_safe_and_not_terminal_text ) { EXPECT_EQ( static_cast( 'K' ), display->mSecondGlyph.u ); } +UTEST( eterm, kitty_graphics_bulk_apc_accepts_every_input_split_boundary ) { + const std::string stream = "\033_Ga=T,f=32,s=1,v=1,q=2;AQIDBA==\033\\"; + for ( size_t split = 1; split < stream.size(); ++split ) { + auto pty = std::make_unique(); + pty->mBuffer = stream; + pty->mLoopWrites = false; + pty->mReadSizes = { split, stream.size() - split }; + auto process = std::make_unique(); + auto display = std::make_shared(); + auto term = + TerminalEmulator::create( std::move( pty ), std::move( process ), display, 100 ); + term->update(); + while ( !term->update() ) { + } + ASSERT_TRUE( display->mGraphics != nullptr ); + ASSERT_EQ( static_cast( 1 ), display->mGraphics->placements.size() ); + } +} + +UTEST( eterm, kitty_graphics_strict_base64_rejects_invalid_payload_bytes ) { + KittyGraphicsProtocol protocol; + EXPECT_EQ( KittyGraphicsError::InvalidData, + protocol.handle( "a=t,f=32,s=1,v=1;AQI BA==" ).error ); + EXPECT_EQ( KittyGraphicsError::InvalidData, + protocol.handle( "a=t,f=32,s=1,v=1;AQIDBA=$" ).error ); + EXPECT_EQ( KittyGraphicsError::InvalidData, + protocol.handle( "a=t,f=32,s=1,v=1;AQ=DBA==" ).error ); + EXPECT_EQ( KittyGraphicsError::InvalidData, protocol.handle( "a=t,f=32,s=1,v=1;AB==" ).error ); +} + +#if EE_PLATFORM != EE_PLATFORM_WIN && EE_PLATFORM != EE_PLATFORM_EMSCRIPTEN +UTEST( eterm, kitty_graphics_reads_and_unlinks_posix_shared_memory ) { + // mpv uses the Linux-compatible form without the optional leading slash. + const std::string name = "eterm-kitty-unit-" + std::to_string( getpid() ); + shm_unlink( name.c_str() ); + const int descriptor = shm_open( name.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600 ); + ASSERT_TRUE( descriptor >= 0 ); + const Uint8 stored[] = { 99, 98, 1, 2, 3 }; + ASSERT_EQ( static_cast( sizeof( stored ) ), + write( descriptor, stored, sizeof( stored ) ) ); + close( descriptor ); + + std::string encodedName; + ASSERT_TRUE( Base64::encode( name, encodedName ) ); + KittyGraphicsProtocol protocol; + EXPECT_EQ( KittyGraphicsError::None, + protocol.handle( "a=T,t=s,f=24,s=1,v=1,O=2,S=3,q=2,m=1;" + encodedName ).error ); + auto updates = protocol.takeUpdates(); + ASSERT_EQ( static_cast( 1 ), updates.size() ); + ASSERT_TRUE( updates[0].rgba != nullptr ); + const std::vector expected{ 1, 2, 3, 255 }; + EXPECT_TRUE( expected == *updates[0].rgba ); + EXPECT_EQ( -1, shm_open( name.c_str(), O_RDONLY, 0 ) ); +} +#endif + UTEST( eterm, kitty_graphics_accepts_unchunked_direct_image_larger_than_eight_kibibytes ) { std::vector rgb( 64 * 64 * 3 ); for ( size_t offset = 0; offset < rgb.size(); offset += 3 )