From 03391217488e59d55727b03b31ce2c53247b1255 Mon Sep 17 00:00:00 2001 From: Luke Kuzmish <42181698+cosmastech@users.noreply.github.com> Date: Fri, 10 Jan 2025 04:00:11 -0500 Subject: [PATCH] fix(cli): handle overflow in `promptSecret` (#6318) --- cli/prompt_secret.ts | 34 +++++- cli/prompt_secret_test.ts | 220 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/cli/prompt_secret.ts b/cli/prompt_secret.ts index 50b700f4c04c..d16df7f309d3 100644 --- a/cli/prompt_secret.ts +++ b/cli/prompt_secret.ts @@ -9,6 +9,7 @@ const CR = "\r".charCodeAt(0); // ^M - Enter on macOS and Windows (CRLF) const BS = "\b".charCodeAt(0); // ^H - Backspace on Linux and Windows const DEL = 0x7f; // ^? - Backspace on macOS const CLR = encoder.encode("\r\u001b[K"); // Clear the current line +const MOVE_LINE_UP = encoder.encode("\r\u001b[1F"); // Move to previous line // The `cbreak` option is not supported on Windows const setRawOptions = Deno.build.os === "windows" @@ -52,12 +53,41 @@ export function promptSecret( return null; } + const { columns } = Deno.consoleSize(); + let previousLength = 0; // Make the output consistent with the built-in prompt() message += " "; const callback = !mask ? undefined : (n: number) => { - output.writeSync(CLR); - output.writeSync(encoder.encode(`${message}${mask.repeat(n)}`)); + let line = `${message}${mask.repeat(n)}`; + const currentLength = line.length; + const charsPastLineLength = line.length % columns; + + if (line.length > columns) { + line = line.slice( + -1 * (charsPastLineLength === 0 ? columns : charsPastLineLength), + ); + } + + // If the user has deleted a character + if (currentLength < previousLength) { + // Then clear the current line. + output.writeSync(CLR); + if (charsPastLineLength === 0) { + // And if there's no characters on the current line, return to previous line. + output.writeSync(MOVE_LINE_UP); + } + } else { + // Always jump the cursor back to the beginning of the line unless it's the first character. + if (charsPastLineLength !== 1) { + output.writeSync(CLR); + } + } + + output.writeSync(encoder.encode(line)); + + previousLength = currentLength; }; + output.writeSync(encoder.encode(message)); Deno.stdin.setRaw(true, setRawOptions); diff --git a/cli/prompt_secret_test.ts b/cli/prompt_secret_test.ts index 043c071a77ce..2215f33ad488 100644 --- a/cli/prompt_secret_test.ts +++ b/cli/prompt_secret_test.ts @@ -10,6 +10,9 @@ const decoder = new TextDecoder(); Deno.test("promptSecret() handles CR", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -54,6 +57,9 @@ Deno.test("promptSecret() handles CR", () => { Deno.test("promptSecret() handles LF", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -98,6 +104,9 @@ Deno.test("promptSecret() handles LF", () => { Deno.test("promptSecret() handles input", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -155,6 +164,9 @@ Deno.test("promptSecret() handles input", () => { Deno.test("promptSecret() handles DEL", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -218,6 +230,9 @@ Deno.test("promptSecret() handles DEL", () => { Deno.test("promptSecret() handles BS", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -281,6 +296,9 @@ Deno.test("promptSecret() handles BS", () => { Deno.test("promptSecret() handles clear option", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -340,6 +358,9 @@ Deno.test("promptSecret() handles clear option", () => { Deno.test("promptSecret() handles mask option", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -397,6 +418,9 @@ Deno.test("promptSecret() handles mask option", () => { Deno.test("promptSecret() handles empty mask option", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -470,6 +494,9 @@ Deno.test("promptSecret() returns null if Deno.stdin.isTerminal() is false", () Deno.test("promptSecret() handles null readSync", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -500,6 +527,9 @@ Deno.test("promptSecret() handles null readSync", () => { Deno.test("promptSecret() handles empty readSync", () => { stub(Deno.stdin, "setRaw"); stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 80, rows: 20 }; + }); const expectedOutput = [ "Please provide the password: ", @@ -526,3 +556,193 @@ Deno.test("promptSecret() handles empty readSync", () => { assertEquals(expectedOutput, actualOutput); restore(); }); + +Deno.test("promptSecret() wraps characters wider than console columns", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 5, rows: 20 }; + }); + + const expectedOutput = [ + "? ", + "\r\x1b[K", + "? *", + "\r\x1b[K", + "? **", + "\r\x1b[K", + "? ***", + "*", + "\r\x1b[K", + "**", + "\r\x1b[K", + "***", + "\r\x1b[K", + "****", + "\r\x1b[K", + "*****", + "*", + "\r\x1b[K", + "**", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "d", + "e", + "n", + "o", + " ", + "r", + "u", + "l", + "e", + "s", + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const password = promptSecret("?"); + + assertEquals(password, "deno rules"); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptSecret() returns to previous line when deleting characters", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + stub(Deno, "consoleSize", () => { + return { columns: 6, rows: 20 }; + }); + + const expectedOutput = [ + "? ", + "\r\u001b[K", + "? *", + "\r\u001b[K", + "? **", + "\r\u001b[K", + "? ***", + "\r\u001b[K", + "? ****", + "*", + "\r\u001b[K", + "**", + "\r\u001b[K", + "***", + "\r\u001b[K", + "****", + "\r\u001b[K", + "*****", + "\r\u001b[K", + "******", + "*", + "\r\u001b[K", + "**", + "\r\u001b[K", + "***", + "\r\u001b[K", + "**", + "\r\u001b[K", + "*", + "\r\u001b[K", + "\r\u001b[1F", + "******", + "\r\u001b[K", + "*****", + "\r\u001b[K", + "****", + "\r\u001b[K", + "***", + "\r\u001b[K", + "**", + "\r\u001b[K", + "*", + "\r\u001b[K", + "\r\u001b[1F", + "? ****", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "d", + "e", + "n", + "o", + " ", + "r", + "u", + "l", + "e", + "s", + "!", + "!", + "!", + "\x7f", + "\x7f", + "\x7f", + "\x7f", + "\x7f", + "\x7f", + "\x7f", + "\x7f", + "\x7f", + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const password = promptSecret("?"); + + assertEquals(password, "deno"); + assertEquals(expectedOutput, actualOutput); + restore(); +});