From 9e014539366f79383139f532d07195407329b635 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:46:12 +0000 Subject: [PATCH] fix(mobile): gate Android terminal echo on a finished pty echo A single matching byte, or a prefix of typed input, was enough to treat the pty as echoing. Local echo now stays off until the pty finishes the outstanding input and has echoed at least two characters on the line, so an echo-disabled prompt cannot paint the next secret key. Co-authored-by: maco --- .../modules/t3-terminal/android/build.gradle | 1 + .../expo/modules/t3terminal/T3TerminalView.kt | 107 ++++- .../modules/t3terminal/TerminalLocalEcho.kt | 406 ++++++++++++++++++ .../t3terminal/TerminalLocalEchoTest.kt | 180 ++++++++ 4 files changed, 671 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt create mode 100644 apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index 0da0777cf4ff..85a2f1220a03 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -30,4 +30,5 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 94e0b38e51d6..e458ed71c372 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -48,7 +48,9 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex onCapture(mapOf("text" to text)) } private var terminalHandle = 0L - private var fedBuffer = "" + + /** Local-echo cursor. [TerminalLocalEcho] keeps this aligned with Ghostty. */ + private var echoState = TerminalEchoState() private var cols = 0 private var rows = 0 private var clearingInput = false @@ -245,7 +247,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val isEnter = isImeSend || isHardwareEnter if (isEnter) { // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline). - onInput(mapOf("data" to "\r")) + emitTypedInput("\r") true } else { false @@ -255,14 +257,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { keyCode == KeyEvent.KEYCODE_DEL -> { - onInput(mapOf("data" to "\u007F")) + emitTypedInput("\u007F") true } // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...). event.isCtrlPressed && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> { - onInput( - mapOf("data" to (keyCode - KeyEvent.KEYCODE_A + 1).toChar().toString()), - ) + emitTypedInput((keyCode - KeyEvent.KEYCODE_A + 1).toChar().toString()) true } else -> false @@ -278,7 +278,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (start >= end) return val insertedText = s.subSequence(start, end).toString() if (insertedText.isNotEmpty()) { - onInput(mapOf("data" to insertedText)) + emitTypedInput(insertedText) } } @@ -343,12 +343,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex cursorColorValue, paletteColors, ) - fedBuffer = "" } private fun recreateTerminal() { if (terminalHandle == 0L) return destroyTerminal() + echoState = TerminalEchoState() createTerminal() feedPendingBuffer() renderSnapshot() @@ -358,30 +358,91 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (terminalHandle == 0L) return GhosttyBridge.nativeDestroy(terminalHandle) terminalHandle = 0L - fedBuffer = "" terminalCanvas.resetSelectionState() } + /** + * Reconciles [initialBuffer] with keystrokes already painted locally. + * A confirmed echo is not fed again. Any other pty bytes clear the echo + * gate and are drawn from the authoritative buffer. + */ private fun feedPendingBuffer() { - if (terminalHandle == 0L || initialBuffer == fedBuffer) return - if (!initialBuffer.startsWith(fedBuffer)) { - recreateTerminal() - if (terminalHandle == 0L) return + if (terminalHandle == 0L) return + when (val sync = TerminalLocalEcho.applyRemoteBuffer(echoState, initialBuffer)) { + is TerminalBufferSync.InSync -> echoState = sync.state + is TerminalBufferSync.Feed -> applyRemoteSuffix(sync) + is TerminalBufferSync.Reset -> rebuildFromRemote(sync) } - val suffix = initialBuffer.substring(fedBuffer.length) - if (suffix.isNotEmpty()) { - emitResponse(GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8))) - // New output invalidates an active selection (matches the web drawer); - // otherwise the copy toolbar drifts out of sync with the grid. - if (terminalCanvas.hasActiveSelection()) { - GhosttyBridge.nativeClearSelection(terminalHandle) - terminalCanvas.resetSelectionState() - } + } + + /** + * Sends [data] to the remote pty. Printable typing is painted only after + * the pty has finished echoing at least two characters on the line. + * Echo-off prompts, including `read -s -p 'API token: '`, are not painted. + */ + private fun emitTypedInput(data: String) { + if (data.isEmpty() || isCleanedUp) return + val decision = TerminalLocalEcho.noteLocalInput( + echoState, + data, + terminalHandle != 0L, + ) + val paint = decision.paint + echoState = decision.state + if (paint != null) { + feedBytes(paint) + clearSelectionAfterOutput() + renderSnapshot() + } + onInput(mapOf("data" to data)) + } + + private fun applyRemoteSuffix(sync: TerminalBufferSync.Feed) { + echoState = sync.state + feedBytes(sync.suffix) + clearSelectionAfterOutput() + renderSnapshot() + } + + /** + * Discards the Ghostty session and replays [TerminalBufferSync.Reset.buffer]. + * Bytes before [TerminalBufferSync.Reset.replyFrom] were already answered, so + * their device replies are dropped. + */ + private fun rebuildFromRemote(sync: TerminalBufferSync.Reset) { + destroyTerminal() + createTerminal() + if (terminalHandle == 0L) { + echoState = TerminalEchoState() + return + } + val replyFrom = sync.replyFrom.coerceIn(0, sync.buffer.length) + val history = sync.buffer.substring(0, replyFrom) + val live = sync.buffer.substring(replyFrom) + if (history.isNotEmpty()) { + GhosttyBridge.nativeFeed(terminalHandle, history.toByteArray(Charsets.UTF_8)) + } + if (live.isNotEmpty()) { + feedBytes(live) } - fedBuffer = initialBuffer + if (sync.buffer.isNotEmpty()) { + clearSelectionAfterOutput() + } + echoState = sync.state renderSnapshot() } + private fun feedBytes(data: String) { + if (terminalHandle == 0L || data.isEmpty()) return + emitResponse(GhosttyBridge.nativeFeed(terminalHandle, data.toByteArray(Charsets.UTF_8))) + } + + private fun clearSelectionAfterOutput() { + if (terminalHandle == 0L || !terminalCanvas.hasActiveSelection()) return + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + private fun renderSnapshot() { if (terminalHandle == 0L) return TerminalFrame.decode( diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt new file mode 100644 index 000000000000..cc0621ece377 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt @@ -0,0 +1,406 @@ +package expo.modules.t3terminal + +/** + * Paints printable Android keystrokes before the remote pty echoes them. + * + * Echo-off is not on the wire. A proper prefix of unsent input, or a single + * echoed byte, is not proof the pty is echoing: unrelated output such as `a` + * matches the start of a secret. Painting starts only after the pty has + * finished echoing at least two typed characters on the line, with nothing + * else mixed in. Any other output closes the gate. + */ +internal object TerminalLocalEcho { + /** One IME delivery longer than this is a paste, not typing. */ + private const val MAX_PREDICTABLE_INPUT = 64 + + /** + * Cap for input the pty has not echoed yet. A trusted key that would pass + * it is not painted and stays in [TerminalEchoState.pendingInput] so the + * next key cannot be drawn ahead of it. + */ + internal const val MAX_PENDING_INPUT = 256 + + /** + * One matching byte is not an authoritative echo. Trust requires the pty + * to finish the outstanding input and to have echoed at least this many + * characters on the line. + */ + private const val MIN_AUTHORITATIVE_ECHO = 2 + + /** + * Records [input] and, when echo is known to be on, returns the bytes to + * paint. [canPaint] is false until the native terminal exists. + */ + fun noteLocalInput( + state: TerminalEchoState, + input: String, + canPaint: Boolean, + ): LocalEchoDecision { + val echo = predictableEcho(input) + if (echo == null || !canPaintAhead(state, echo, canPaint)) { + return skipPaint(state, echo, input, canPaint) + } + return LocalEchoDecision( + paint = echo, + state = state.copy( + pendingInput = state.pendingInput + echo, + predicted = state.predicted + echo, + ), + ) + } + + /** + * Folds the authoritative pty buffer into [state]. A confirmed prediction + * is not fed again. Anything other than a finished echo of the outstanding + * input clears the echo gate. A contradiction rebuilds from [remote]. + */ + fun applyRemoteBuffer(state: TerminalEchoState, remote: String): TerminalBufferSync { + if (!remote.startsWith(state.confirmed)) { + return resetTo(remote, replyFrom = retainedHistoryLength(state.confirmed, remote)) + } + val suffix = remote.substring(state.confirmed.length) + val withHold = state.copy(awaitingRemote = stillAwaitingRemote(state, suffix)) + return if (withHold.predicted.isEmpty()) { + syncUnpredicted(withHold, remote, suffix) + } else { + syncPredicted(withHold, remote, suffix) + } + } + + /** Short printable input a cooked shell echoes unchanged, or null. */ + fun predictableEcho(input: String): String? { + if (input.isEmpty() || input.length > MAX_PREDICTABLE_INPUT) return null + var index = 0 + while (index < input.length) { + val codePoint = input.codePointAt(index) + if (codePoint < 0x20 || codePoint == 0x7F || codePoint in 0x80..0x9F) return null + index += Character.charCount(codePoint) + } + return input + } + + private fun canPaintAhead( + state: TerminalEchoState, + echo: String, + canPaint: Boolean, + ): Boolean { + if (!canPaint || !echoKnownOn(state)) return false + return state.predicted.length + echo.length <= MAX_PENDING_INPUT + } + + /** + * Echo is known on only after an authoritative echo, with no control key + * still in flight and no unpainted printable bytes ahead of the cursor. + */ + private fun echoKnownOn(state: TerminalEchoState): Boolean = + state.echoTrusted && !state.awaitingRemote && state.pendingInput == state.predicted + + private fun skipPaint( + state: TerminalEchoState, + echo: String?, + input: String, + canPaint: Boolean, + ): LocalEchoDecision { + val held = if (input.isNotEmpty() && echo == null) { + state.copy(awaitingRemote = true, awaitingEchoPast = state.pendingInput.length) + } else { + state + } + val next = when { + echo != null && shouldRetainTrustedCap(held, echo, canPaint) -> + held.copy(pendingInput = held.pendingInput + echo) + echo != null && held.echoTrusted && !echoKnownOn(held) -> holdUnpainted(held, echo) + echo == null || held.echoTrusted -> held + else -> held.copy(pendingInput = rememberPending(held.pendingInput, echo)) + } + return LocalEchoDecision(paint = null, state = next) + } + + /** + * Keeps an unpainted trusted key in [TerminalEchoState.pendingInput] so a + * later partial echo cannot draw the key after it. A key that would grow + * pending past [MAX_PENDING_INPUT] holds the echo gate until the pty sends + * something other than the bytes already recorded. + */ + private fun holdUnpainted(state: TerminalEchoState, echo: String): TerminalEchoState { + if (state.pendingInput.length + echo.length <= MAX_PENDING_INPUT) { + return state.copy(pendingInput = state.pendingInput + echo) + } + return state.copy(awaitingRemote = true) + } + + private fun shouldRetainTrustedCap( + state: TerminalEchoState, + echo: String, + canPaint: Boolean, + ): Boolean { + if (!canPaint || !echoKnownOn(state)) return false + return state.predicted.length + echo.length > MAX_PENDING_INPUT + } + + /** + * A control key stays unanswered while the new pty bytes are only the + * printable echo already recorded. Bytes typed after the key clear the + * hold once they are echoed, even when the key itself produced no output. + */ + private fun stillAwaitingRemote(state: TerminalEchoState, suffix: String): Boolean { + if (!state.awaitingRemote) return false + val outstanding = outstandingPrintable(state) + val echoedPastControl = suffix.length > state.awaitingEchoPast && + outstanding.isNotEmpty() && + outstanding.startsWith(suffix) + if (echoedPastControl) return false + return suffix.isEmpty() || (outstanding.isNotEmpty() && outstanding.startsWith(suffix)) + } + + private fun outstandingPrintable(state: TerminalEchoState): String = when { + state.pendingInput.startsWith(state.predicted) -> state.pendingInput + state.predicted.startsWith(state.pendingInput) -> state.predicted + else -> state.predicted.ifEmpty { state.pendingInput } + } + + private fun rememberPending(pending: String, echo: String): String { + val combined = pending + echo + if (combined.length > MAX_PENDING_INPUT) return "" + return combined + } + + private fun syncUnpredicted( + state: TerminalEchoState, + remote: String, + suffix: String, + ): TerminalBufferSync { + val learned = learnEcho(state.pendingInput, suffix) + val next = state.copy( + confirmed = remote, + pendingInput = pendingAfterRemote(state.pendingInput, suffix, learned), + echoTrusted = trustAfterEcho(state, suffix, learned), + echoedOnLine = echoedOnLineAfter(state, suffix, learned), + awaitingEchoPast = advanceHold(state, learned), + hasRemoteOutput = remote.isNotEmpty(), + ) + if (suffix.isEmpty()) return TerminalBufferSync.InSync(next) + return TerminalBufferSync.Feed(suffix, next) + } + + private fun syncPredicted( + state: TerminalEchoState, + remote: String, + suffix: String, + ): TerminalBufferSync = when { + suffix.isEmpty() || state.predicted.startsWith(suffix) -> + confirmPredictedPrefix(state, remote, suffix) + suffix.startsWith(state.predicted) -> + confirmPredictedAndFeedRest(state, remote, suffix) + else -> resetTo(remote, replyFrom = state.confirmed.length) + } + + private fun confirmPredictedPrefix( + state: TerminalEchoState, + remote: String, + suffix: String, + ): TerminalBufferSync { + if (suffix.isEmpty()) return TerminalBufferSync.InSync(state) + return TerminalBufferSync.InSync( + state.copy( + confirmed = remote, + predicted = state.predicted.substring(suffix.length), + pendingInput = dropMatchedPrefix(state.pendingInput, suffix), + echoedOnLine = state.echoedOnLine + suffix.length, + awaitingEchoPast = (state.awaitingEchoPast - suffix.length).coerceAtLeast(0), + hasRemoteOutput = true, + ), + ) + } + + private fun confirmPredictedAndFeedRest( + state: TerminalEchoState, + remote: String, + suffix: String, + ): TerminalBufferSync { + val extra = suffix.substring(state.predicted.length) + val pending = dropMatchedPrefix(state.pendingInput, state.predicted) + val learned = learnEcho(pending, extra) + val finishedExtra = learned.confirmedEcho && learned.pending.isEmpty() && extra == pending + return TerminalBufferSync.Feed( + extra, + state.copy( + confirmed = remote, + predicted = "", + pendingInput = pendingAfterRemote(pending, extra, learned), + echoTrusted = state.echoTrusted && (extra.isEmpty() || finishedExtra), + echoedOnLine = if (extra.isEmpty() || finishedExtra) { + state.echoedOnLine + state.predicted.length + pending.length + } else { + 0 + }, + awaitingEchoPast = 0, + hasRemoteOutput = true, + ), + ) + } + + /** + * Enables the gate only when [suffix] is exactly the outstanding input and + * the line has now echoed at least [MIN_AUTHORITATIVE_ECHO] characters. + * A proper prefix, including a lone `a`, does not enable it. + */ + private fun trustAfterEcho( + state: TerminalEchoState, + suffix: String, + learned: LearnedEcho, + ): Boolean { + if (suffix.isEmpty()) return state.echoTrusted + if (!finishedEcho(state.pendingInput, suffix, learned)) return false + return state.echoedOnLine + state.pendingInput.length >= MIN_AUTHORITATIVE_ECHO + } + + private fun echoedOnLineAfter( + state: TerminalEchoState, + suffix: String, + learned: LearnedEcho, + ): Int { + if (suffix.isEmpty()) return state.echoedOnLine + val consumed = if (learned.confirmedEcho) { + state.pendingInput.length - learned.pending.length + } else { + 0 + } + if (consumed == 0) return 0 + return state.echoedOnLine + consumed + } + + /** The pty returned the whole outstanding input and nothing else. */ + private fun finishedEcho(pending: String, suffix: String, learned: LearnedEcho): Boolean = + pending.isNotEmpty() && + learned.confirmedEcho && + learned.pending.isEmpty() && + suffix.length == pending.length + + private fun advanceHold(state: TerminalEchoState, learned: LearnedEcho): Int { + if (!learned.confirmedEcho) return 0 + val consumed = state.pendingInput.length - learned.pending.length + return (state.awaitingEchoPast - consumed).coerceAtLeast(0) + } + + private fun pendingAfterRemote( + pending: String, + suffix: String, + learned: LearnedEcho, + ): String { + if (suffix.isNotEmpty() && !learned.confirmedEcho) return "" + return if (suffix.isEmpty()) pending else learned.pending + } + + private fun learnEcho(pending: String, suffix: String): LearnedEcho = when { + pending.isEmpty() || suffix.isEmpty() -> LearnedEcho(pending, confirmedEcho = false) + suffix.startsWith(pending) -> LearnedEcho("", confirmedEcho = true) + pending.startsWith(suffix) -> + LearnedEcho(pending.substring(suffix.length), confirmedEcho = true) + else -> LearnedEcho(pending, confirmedEcho = false) + } + + private fun dropMatchedPrefix(pending: String, prefix: String): String = when { + prefix.isEmpty() -> pending + pending.startsWith(prefix) -> pending.substring(prefix.length) + else -> "" + } + + /** + * Length of the longest suffix of [previous] that is a prefix of [remote]. + * A scrollback trim drops the head and may append bytes in the same update. + * That overlap was already answered, so device replies are not resent. + */ + internal fun retainedHistoryLength(previous: String, remote: String): Int { + if (previous.isEmpty() || remote.isEmpty()) return 0 + if (remote.startsWith(previous)) return previous.length + val border = prefixBorder(remote) + var matched = 0 + for (index in previous.indices) { + while (matched > 0 && previous[index] != remote[matched]) { + matched = border[matched - 1] + } + if (previous[index] == remote[matched]) matched++ + if (matched == remote.length && index != previous.lastIndex) { + matched = border[matched - 1] + } + } + return matched + } + + private fun prefixBorder(pattern: String): IntArray { + val border = IntArray(pattern.length) + var length = 0 + var index = 1 + while (index < pattern.length) { + if (pattern[index] == pattern[length]) { + length++ + border[index] = length + index++ + } else if (length > 0) { + length = border[length - 1] + } else { + index++ + } + } + return border + } + + private fun resetTo(remote: String, replyFrom: Int): TerminalBufferSync = TerminalBufferSync.Reset( + buffer = remote, + replyFrom = replyFrom, + state = TerminalEchoState( + confirmed = remote, + echoTrusted = false, + hasRemoteOutput = remote.isNotEmpty(), + ), + ) + + private data class LearnedEcho(val pending: String, val confirmedEcho: Boolean) +} + +/** + * [confirmed] is pty output already applied. [predicted] is the painted tail + * still absent from [confirmed]. [pendingInput] is predictable input not yet + * observed. [echoTrusted] is set only after a finished multi-character echo + * and cleared when the pty sends anything else. [awaitingRemote] holds + * painting after a control key. [awaitingEchoPast] is the pending length at + * that key, so a later echoed character can release the hold when the key + * itself produced no output. [echoedOnLine] counts consecutive echoed + * characters since the last non-echo byte. + */ +internal data class TerminalEchoState( + val confirmed: String = "", + val predicted: String = "", + val pendingInput: String = "", + val echoTrusted: Boolean = false, + val hasRemoteOutput: Boolean = false, + val awaitingRemote: Boolean = false, + val awaitingEchoPast: Int = 0, + val echoedOnLine: Int = 0, +) + +/** [paint] is null when the key must wait for the pty. */ +internal data class LocalEchoDecision( + val paint: String?, + val state: TerminalEchoState, +) + +/** How to update Ghostty after a remote buffer arrives. */ +internal sealed interface TerminalBufferSync { + val state: TerminalEchoState + + data class InSync(override val state: TerminalEchoState) : TerminalBufferSync + + data class Feed(val suffix: String, override val state: TerminalEchoState) : TerminalBufferSync + + /** + * Replay [buffer]. [replyFrom] is where device replies may resume; earlier + * bytes were already answered by the previous session. + */ + data class Reset( + val buffer: String, + val replyFrom: Int, + override val state: TerminalEchoState, + ) : TerminalBufferSync +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt new file mode 100644 index 000000000000..7afa66b9c7fe --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt @@ -0,0 +1,180 @@ +package expo.modules.t3terminal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalLocalEchoTest { + @Test + fun thirdKeyPaintsOnlyAfterTwoFinishedEchoes() { + val prompt = note("user@host:~$ ") + val first = TerminalLocalEcho.noteLocalInput(prompt, "l", canPaint = true) + assertNull(first.paint) + assertFalse(first.state.echoTrusted) + + val echoedFirst = TerminalLocalEcho.applyRemoteBuffer(first.state, "user@host:~$ l") + assertFalse(echoedFirst.state.echoTrusted) + assertNull(TerminalLocalEcho.noteLocalInput(echoedFirst.state, "s", canPaint = true).paint) + + val second = TerminalLocalEcho.noteLocalInput(echoedFirst.state, "s", canPaint = true) + val echoedSecond = TerminalLocalEcho.applyRemoteBuffer(second.state, "user@host:~$ ls") + assertTrue(echoedSecond.state.echoTrusted) + assertEquals("", echoedSecond.state.pendingInput) + + val third = TerminalLocalEcho.noteLocalInput(echoedSecond.state, " ", canPaint = true) + assertEquals(" ", third.paint) + assertEquals(" ", third.state.predicted) + } + + @Test + fun unrelatedPrefixDoesNotEnableEcho() { + var state = note("API token: ") + for (ch in "secret") { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertNull(decision.paint) + state = decision.state + } + val coincidental = TerminalLocalEcho.applyRemoteBuffer(state, "API token: a") + assertFalse(coincidental.state.echoTrusted) + val next = TerminalLocalEcho.noteLocalInput(coincidental.state, "x", canPaint = true) + assertNull(next.paint) + assertEquals("", next.state.predicted) + } + + @Test + fun singleEchoedByteDoesNotEnableEcho() { + val first = TerminalLocalEcho.noteLocalInput(note("API token: "), "a", canPaint = true) + val echoed = TerminalLocalEcho.applyRemoteBuffer(first.state, "API token: a") + assertFalse(echoed.state.echoTrusted) + val next = TerminalLocalEcho.noteLocalInput(echoed.state, "b", canPaint = true) + assertNull(next.paint) + } + + @Test + fun echoDisabledPromptNeverPaintsTheSecret() { + val typed = typeUnpainted("read -s") + val entered = TerminalLocalEcho.noteLocalInput(typed, "\r", canPaint = true) + assertTrue(entered.state.awaitingRemote) + val prompted = TerminalLocalEcho.applyRemoteBuffer( + entered.state, + "user@host:~$ read -s\r\nAPI token: ", + ) + assertFalse(prompted.state.echoTrusted) + assertFalse(prompted.state.awaitingRemote) + + var state = prompted.state + for (ch in "s3cret") { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertNull(decision.paint) + state = decision.state + } + val hidden = TerminalLocalEcho.applyRemoteBuffer(state, "user@host:~$ read -s\r\nAPI token: ") + assertFalse(hidden.state.echoTrusted) + assertEquals("", hidden.state.predicted) + } + + @Test + fun keyPastThePendingCapIsNotPaintedAndBlocksTheNextKey() { + var state = trustedLine("prompt ") + val filled = "x".repeat(TerminalLocalEcho.MAX_PENDING_INPUT) + for (ch in filled) { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertEquals(ch.toString(), decision.paint) + state = decision.state + } + + val overflow = TerminalLocalEcho.noteLocalInput(state, "y", canPaint = true) + assertNull(overflow.paint) + assertTrue(overflow.state.pendingInput.endsWith("y")) + assertFalse(overflow.state.pendingInput == overflow.state.predicted) + + val partial = TerminalLocalEcho.applyRemoteBuffer(overflow.state, "prompt ab$filled") + assertNull(TerminalLocalEcho.noteLocalInput(partial.state, "z", canPaint = true).paint) + + val echoed = TerminalLocalEcho.applyRemoteBuffer(overflow.state, "prompt ab$filled" + "y") + assertEquals("", echoed.state.pendingInput) + assertTrue(echoed.state.echoTrusted) + val after = TerminalLocalEcho.noteLocalInput(echoed.state, "q", canPaint = true) + assertEquals("q", after.paint) + + val blocked = TerminalLocalEcho.noteLocalInput(overflow.state, "z", canPaint = true) + assertTrue(blocked.state.awaitingRemote) + val echoedPast = TerminalLocalEcho.applyRemoteBuffer(blocked.state, "prompt ab$filled" + "yz") + assertFalse(echoedPast.state.echoTrusted) + assertNull(TerminalLocalEcho.noteLocalInput(echoedPast.state, "q", canPaint = true).paint) + } + + @Test + fun awaitingRemoteStaysSetWhileSuffixIsOnlyPrintableEcho() { + val typed = typeUnpainted("ls") + val entered = TerminalLocalEcho.noteLocalInput(typed, "\r", canPaint = true) + val echoOnly = TerminalLocalEcho.applyRemoteBuffer(entered.state, "user@host:~$ ls") + assertTrue(echoOnly.state.awaitingRemote) + assertNull(TerminalLocalEcho.noteLocalInput(echoOnly.state, "x", canPaint = true).paint) + + val processed = TerminalLocalEcho.applyRemoteBuffer(echoOnly.state, "user@host:~$ ls\r\n") + assertFalse(processed.state.awaitingRemote) + assertFalse(processed.state.echoTrusted) + } + + @Test + fun silentControlKeyDoesNotBlockLaterEchoedKeys() { + val trusted = trustedLine("prompt ") + val deleted = TerminalLocalEcho.noteLocalInput(trusted, "\u007F", canPaint = true) + assertTrue(deleted.state.awaitingRemote) + val typed = TerminalLocalEcho.noteLocalInput(deleted.state, "c", canPaint = true) + assertNull(typed.paint) + val echoed = TerminalLocalEcho.applyRemoteBuffer(typed.state, "prompt abc") + assertFalse(echoed.state.awaitingRemote) + assertEquals("d", TerminalLocalEcho.noteLocalInput(echoed.state, "d", canPaint = true).paint) + } + + @Test + fun divergentRemoteClearsEchoAndDoesNotKeepAPartialPaint() { + val trusted = trustedLine("prompt ") + val painted = TerminalLocalEcho.noteLocalInput(trusted, "bc", canPaint = true) + val reset = TerminalLocalEcho.applyRemoteBuffer(painted.state, "OTHER") + assertTrue(reset is TerminalBufferSync.Reset) + assertFalse(reset.state.echoTrusted) + assertEquals("", reset.state.predicted) + assertNull(TerminalLocalEcho.noteLocalInput(reset.state, "z", canPaint = true).paint) + } + + @Test + fun scrollbackTrimDoesNotResendTheOverlappingTail() { + val previous = "HEAD" + "tail-already-answered" + val remote = "tail-already-answered" + "new" + assertEquals( + "tail-already-answered".length, + TerminalLocalEcho.retainedHistoryLength(previous, remote), + ) + } + + private fun note(remote: String): TerminalEchoState { + val sync = TerminalLocalEcho.applyRemoteBuffer(TerminalEchoState(), remote) + assertFalse(sync.state.echoTrusted) + return sync.state + } + + private fun typeUnpainted(text: String): TerminalEchoState { + var state = note("user@host:~$ ") + for (ch in text) { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertNull(decision.paint) + state = decision.state + } + return state + } + + private fun trustedLine(prompt: String): TerminalEchoState { + val first = TerminalLocalEcho.noteLocalInput(note(prompt), "a", canPaint = true) + val mid = TerminalLocalEcho.applyRemoteBuffer(first.state, prompt + "a") + assertFalse(mid.state.echoTrusted) + val second = TerminalLocalEcho.noteLocalInput(mid.state, "b", canPaint = true) + val echoed = TerminalLocalEcho.applyRemoteBuffer(second.state, prompt + "ab") + assertTrue(echoed.state.echoTrusted) + return echoed.state + } +}