Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cli): Handle overflow in promptSecret #6318

Merged
merged 11 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions cli/prompt_secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Comment on lines +76 to +77
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks nicely commented 👍

}
} 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);
Expand Down
220 changes: 220 additions & 0 deletions cli/prompt_secret_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand Down Expand Up @@ -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: ",
Expand All @@ -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", () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice test case!

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();
});
Loading