mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
createSudoPasswordAutofill,
|
|
prepareSudoAutofillInput,
|
|
} from "./terminalSudoAutofill";
|
|
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
|
|
import { createPromptLineBreakState } from "./promptLineBreak";
|
|
|
|
function createFakeTerm(lineText = "$ echo ok", cursorX = lineText.length) {
|
|
return {
|
|
buffer: {
|
|
active: {
|
|
cursorX,
|
|
cursorY: 0,
|
|
baseY: 0,
|
|
getLine(line: number) {
|
|
if (line !== 0) return undefined;
|
|
return {
|
|
isWrapped: false,
|
|
translateToString() {
|
|
return lineText;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
|
return {
|
|
cols,
|
|
buffer: {
|
|
active: {
|
|
cursorX,
|
|
cursorY,
|
|
baseY: 0,
|
|
getLine(line: number) {
|
|
const lineText = rows[line];
|
|
if (lineText === undefined) return undefined;
|
|
return {
|
|
isWrapped: line > 0,
|
|
translateToString() {
|
|
return lineText;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
test("sudo autofill input preparation arms on a submitted sudo command without altering input", () => {
|
|
const writes: string[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
password: "secret",
|
|
write: (data) => writes.push(data),
|
|
onHint: () => true,
|
|
});
|
|
|
|
assert.equal(prepareSudoAutofillInput("\r", "sudo whoami", autofill), "\r");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["secret\n"]);
|
|
});
|
|
|
|
test("sudo autofill input preparation arms on a single-line pasted sudo command", () => {
|
|
const writes: string[] = [];
|
|
const autofill = createSudoPasswordAutofill({
|
|
password: "secret",
|
|
write: (data) => writes.push(data),
|
|
onHint: () => true,
|
|
});
|
|
|
|
assert.equal(prepareSudoAutofillInput("sudo whoami\n", null, autofill), "sudo whoami\n");
|
|
autofill.handleOutput("[sudo] password for alice: ");
|
|
autofill.confirmFill();
|
|
assert.deepEqual(writes, ["secret\n"]);
|
|
});
|
|
|
|
test("sudo autofill input preparation preserves bracketed pasted sudo commands", () => {
|
|
const autofill = createSudoPasswordAutofill({
|
|
password: "secret",
|
|
write: () => {},
|
|
});
|
|
|
|
assert.equal(
|
|
prepareSudoAutofillInput("\x1b[200~sudo whoami\n\x1b[201~", null, autofill),
|
|
"\x1b[200~sudo whoami\n\x1b[201~",
|
|
);
|
|
});
|
|
|
|
test("sudo autofill input preparation leaves ordinary commands unchanged", () => {
|
|
const autofill = createSudoPasswordAutofill({
|
|
password: "secret",
|
|
write: () => {},
|
|
});
|
|
|
|
assert.equal(prepareSudoAutofillInput("\r", "echo ok", autofill), "\r");
|
|
assert.equal(prepareSudoAutofillInput("x", "sudo whoami", autofill), "x");
|
|
assert.equal(prepareSudoAutofillInput("sudo whoami\nsudo id\n", null, autofill), "sudo whoami\nsudo id\n");
|
|
});
|
|
|
|
test("command execution arms prompt line break even without command history callback", () => {
|
|
const promptState = createPromptLineBreakState();
|
|
const commandBufferRef = { current: "echo ok" };
|
|
|
|
const recordedCommand = recordTerminalCommandExecution("echo ok", {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
promptLineBreakStateRef: { current: promptState },
|
|
});
|
|
|
|
assert.equal(commandBufferRef.current, "");
|
|
assert.equal(recordedCommand, "echo ok");
|
|
assert.equal(promptState.pendingCommand, true);
|
|
});
|
|
|
|
test("command execution caches the current prompt instead of prompt-like command text", () => {
|
|
const promptState = createPromptLineBreakState();
|
|
const commandBufferRef = { current: "echo > out" };
|
|
|
|
recordTerminalCommandExecution("echo > out", {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
promptLineBreakStateRef: { current: promptState },
|
|
}, createFakeTerm("$ echo > out") as never);
|
|
|
|
assert.equal(promptState.lastPromptText, "$ ");
|
|
assert.equal(promptState.pendingCommand, true);
|
|
});
|
|
|
|
test("command execution does not write interactive program input to shell history", () => {
|
|
const cases = [
|
|
{ lineText: "sftp> get file", command: "get file" },
|
|
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
|
{ lineText: "hive (default)> select 1", command: "select 1" },
|
|
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
|
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
|
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
|
{ lineText: "pry(main)> whereami", command: "whereami" },
|
|
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
|
{ lineText: "SQL> select 1", command: "select 1" },
|
|
{ lineText: "test> db.stats()", command: "db.stats()" },
|
|
{ lineText: "test> db", command: "db" },
|
|
{ lineText: "test> const x = 1", command: "const x = 1" },
|
|
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
|
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
|
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
|
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
|
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
|
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
|
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
|
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
|
{ lineText: "test> print(1)", command: "print(1)" },
|
|
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
|
{ lineText: "test> rs.status()", command: "rs.status()" },
|
|
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
|
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
|
];
|
|
|
|
for (const { lineText, command } of cases) {
|
|
const commandBufferRef = { current: command };
|
|
const promptState = createPromptLineBreakState();
|
|
const recorded: string[] = [];
|
|
|
|
const recordedCommand = recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
promptLineBreakStateRef: { current: promptState },
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createFakeTerm(lineText) as never);
|
|
|
|
assert.deepEqual(recorded, [], lineText);
|
|
assert.equal(recordedCommand, null, lineText);
|
|
assert.equal(commandBufferRef.current, "", lineText);
|
|
assert.equal(promptState.lastPromptText, "", lineText);
|
|
assert.equal(promptState.pendingCommand, true, lineText);
|
|
}
|
|
});
|
|
|
|
test("command execution does not record interactive input before echo appears", () => {
|
|
const cases = [
|
|
{ lineText: "test> ", command: "rs.status()" },
|
|
{ lineText: "test> ", command: "db" },
|
|
{ lineText: "test> ", command: "const x = 1" },
|
|
{ lineText: "test> ", command: "await db.users.findOne()" },
|
|
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
|
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
|
{ lineText: "rs0 [direct: primary] test> ", command: "db.stats()" },
|
|
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
|
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
|
];
|
|
|
|
for (const { lineText, command } of cases) {
|
|
const commandBufferRef = { current: command };
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createFakeTerm(lineText) as never);
|
|
|
|
assert.deepEqual(recorded, [], lineText);
|
|
assert.equal(commandBufferRef.current, "", lineText);
|
|
}
|
|
});
|
|
|
|
test("command execution publishes submitted commands even when history recording is skipped", () => {
|
|
const commandBufferRef = { current: "cd /srv/app" };
|
|
const history: string[] = [];
|
|
const submitted: string[] = [];
|
|
|
|
const recordedCommand = recordTerminalCommandExecution("cd /srv/app", {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
history.push(nextCommand);
|
|
},
|
|
onCommandSubmitted(nextCommand) {
|
|
submitted.push(nextCommand);
|
|
},
|
|
}, createFakeTerm("sftp> cd /srv/app") as never);
|
|
|
|
assert.deepEqual(history, []);
|
|
assert.deepEqual(submitted, ["cd /srv/app"]);
|
|
assert.equal(recordedCommand, null);
|
|
assert.equal(commandBufferRef.current, "");
|
|
});
|
|
|
|
test("command execution does not record wrapped interactive program input", () => {
|
|
const cases = [
|
|
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
|
{ rows: ["test> d", "b"], command: "db" },
|
|
];
|
|
|
|
for (const { rows, command } of cases) {
|
|
const commandBufferRef = { current: command };
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
|
|
|
assert.deepEqual(recorded, [], rows[0]);
|
|
assert.equal(commandBufferRef.current, "", rows[0]);
|
|
}
|
|
});
|
|
|
|
test("command execution records non-Mongo-looking default-name greater-than prompts", () => {
|
|
const prompts = ["test> ", "admin> ", "local> ", "config> "];
|
|
const commands = ["deploy", "exit", "help", "show dbs"];
|
|
|
|
for (const prompt of prompts) {
|
|
for (const command of commands) {
|
|
const commandBufferRef = { current: command };
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createFakeTerm(`${prompt}${command}`) as never);
|
|
|
|
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
|
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("command execution records wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
|
const cases = [
|
|
{ rows: ["test> hel", "p"], command: "help" },
|
|
{ rows: ["test> show ", "dbs"], command: "show dbs" },
|
|
{ rows: ["admin> ex", "it"], command: "exit" },
|
|
{ rows: ["local> dep", "loy"], command: "deploy" },
|
|
];
|
|
|
|
for (const { rows, command } of cases) {
|
|
const commandBufferRef = { current: command };
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
|
|
|
assert.deepEqual(recorded, [command], rows[0]);
|
|
assert.equal(commandBufferRef.current, "", rows[0]);
|
|
}
|
|
});
|
|
|
|
test("command execution records short commands when standard prompt echo lags by one character", () => {
|
|
const cases = [
|
|
{ lineText: "$ l", command: "ls" },
|
|
{ lineText: "$ c", command: "cd" },
|
|
{ lineText: "prod-web> l", command: "ls" },
|
|
{ lineText: "prod> l", command: "ls" },
|
|
{ lineText: "prod.web> l", command: "ls" },
|
|
{ lineText: "user@host:~$ l", command: "ls" },
|
|
{ lineText: "[user@host ~]$ l", command: "ls" },
|
|
{ lineText: "➜ netcatty $ l", command: "ls" },
|
|
{ lineText: "➜ git l", command: "ls" },
|
|
{ lineText: "➜ git np", command: "npm" },
|
|
];
|
|
|
|
for (const { lineText, command } of cases) {
|
|
const commandBufferRef = { current: command };
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createFakeTerm(lineText) as never);
|
|
|
|
assert.deepEqual(recorded, [command], lineText);
|
|
assert.equal(commandBufferRef.current, "", lineText);
|
|
}
|
|
});
|
|
|
|
test("command execution records direct sends from themed bare directory prompts", () => {
|
|
const cases = [
|
|
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
|
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
|
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
|
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
|
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
|
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
|
];
|
|
|
|
for (const { lineText, command, promptText } of cases) {
|
|
const commandBufferRef = { current: command };
|
|
const promptState = createPromptLineBreakState();
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
promptLineBreakStateRef: { current: promptState },
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createFakeTerm(lineText) as never);
|
|
|
|
assert.deepEqual(recorded, [command], lineText);
|
|
assert.equal(commandBufferRef.current, "", lineText);
|
|
assert.equal(promptState.lastPromptText, promptText, lineText);
|
|
assert.equal(promptState.pendingCommand, true, lineText);
|
|
}
|
|
});
|
|
|
|
test("command execution still records host-style greater-than prompts", () => {
|
|
const prompts = [
|
|
"prod-web> ",
|
|
"prod> ",
|
|
"prod.web> ",
|
|
"server> ",
|
|
"staging> ",
|
|
"webdb> ",
|
|
"prod.db> ",
|
|
];
|
|
const commands = ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)", "db.stats()"];
|
|
|
|
for (const prompt of prompts) {
|
|
for (const command of commands) {
|
|
const commandBufferRef = { current: command };
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createFakeTerm(`${prompt}${command}`) as never);
|
|
|
|
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
|
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("command execution records direct sends from host-style greater-than prompts", () => {
|
|
const cases = [
|
|
{ lineText: "server> ", command: "exit" },
|
|
{ lineText: "staging> ", command: "show dbs" },
|
|
{ lineText: "server> ", command: "db.stats()" },
|
|
{ lineText: "webdb> ", command: "deploy" },
|
|
{ lineText: "prod.db> ", command: "deploy" },
|
|
{ lineText: "test> ", command: "deploy" },
|
|
{ lineText: "test> ", command: "exit" },
|
|
{ lineText: "test> ", command: "help" },
|
|
{ lineText: "test> ", command: "show dbs" },
|
|
{ lineText: "admin> ", command: "deploy" },
|
|
];
|
|
|
|
for (const { lineText, command } of cases) {
|
|
const commandBufferRef = { current: command };
|
|
const recorded: string[] = [];
|
|
|
|
recordTerminalCommandExecution(command, {
|
|
host: {
|
|
id: "host-1",
|
|
label: "Host",
|
|
},
|
|
sessionId: "session-1",
|
|
commandBufferRef,
|
|
onCommandExecuted(nextCommand) {
|
|
recorded.push(nextCommand);
|
|
},
|
|
}, createFakeTerm(lineText) as never);
|
|
|
|
assert.deepEqual(recorded, [command], lineText);
|
|
assert.equal(commandBufferRef.current, "", lineText);
|
|
}
|
|
});
|