typescript-virtual-container 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/README.md +1056 -1239
  2. package/benchmark-results.txt +20 -20
  3. package/dist/SSHMimic/exec.js +2 -2
  4. package/dist/SSHMimic/executor.d.ts +6 -7
  5. package/dist/SSHMimic/executor.d.ts.map +1 -1
  6. package/dist/SSHMimic/executor.js +77 -60
  7. package/dist/SSHMimic/index.d.ts +19 -2
  8. package/dist/SSHMimic/index.d.ts.map +1 -1
  9. package/dist/SSHMimic/index.js +106 -24
  10. package/dist/SSHMimic/sftp.d.ts.map +1 -1
  11. package/dist/SSHMimic/sftp.js +14 -0
  12. package/dist/VirtualFileSystem/index.d.ts +115 -88
  13. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  14. package/dist/VirtualFileSystem/index.js +389 -264
  15. package/dist/VirtualShell/index.d.ts +3 -4
  16. package/dist/VirtualShell/index.d.ts.map +1 -1
  17. package/dist/VirtualShell/index.js +4 -6
  18. package/dist/VirtualShell/shell.d.ts.map +1 -1
  19. package/dist/VirtualShell/shell.js +19 -2
  20. package/dist/VirtualShell/shellParser.d.ts +20 -2
  21. package/dist/VirtualShell/shellParser.d.ts.map +1 -1
  22. package/dist/VirtualShell/shellParser.js +229 -120
  23. package/dist/VirtualUserManager/index.d.ts +25 -0
  24. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  25. package/dist/VirtualUserManager/index.js +33 -0
  26. package/dist/commands/adduser.d.ts.map +1 -1
  27. package/dist/commands/adduser.js +2 -0
  28. package/dist/commands/awk.d.ts +3 -0
  29. package/dist/commands/awk.d.ts.map +1 -0
  30. package/dist/commands/awk.js +29 -0
  31. package/dist/commands/base64.d.ts +3 -0
  32. package/dist/commands/base64.d.ts.map +1 -0
  33. package/dist/commands/base64.js +20 -0
  34. package/dist/commands/cat.d.ts.map +1 -1
  35. package/dist/commands/cat.js +2 -0
  36. package/dist/commands/cd.d.ts.map +1 -1
  37. package/dist/commands/cd.js +2 -0
  38. package/dist/commands/chmod.d.ts +3 -0
  39. package/dist/commands/chmod.d.ts.map +1 -0
  40. package/dist/commands/chmod.js +33 -0
  41. package/dist/commands/clear.d.ts.map +1 -1
  42. package/dist/commands/clear.js +4 -1
  43. package/dist/commands/cp.d.ts +3 -0
  44. package/dist/commands/cp.d.ts.map +1 -0
  45. package/dist/commands/cp.js +70 -0
  46. package/dist/commands/curl.d.ts.map +1 -1
  47. package/dist/commands/curl.js +2 -0
  48. package/dist/commands/cut.d.ts +3 -0
  49. package/dist/commands/cut.d.ts.map +1 -0
  50. package/dist/commands/cut.js +27 -0
  51. package/dist/commands/date.d.ts +3 -0
  52. package/dist/commands/date.d.ts.map +1 -0
  53. package/dist/commands/date.js +22 -0
  54. package/dist/commands/deluser.d.ts.map +1 -1
  55. package/dist/commands/deluser.js +2 -0
  56. package/dist/commands/df.d.ts +3 -0
  57. package/dist/commands/df.d.ts.map +1 -0
  58. package/dist/commands/df.js +16 -0
  59. package/dist/commands/diff.d.ts +3 -0
  60. package/dist/commands/diff.d.ts.map +1 -0
  61. package/dist/commands/diff.js +40 -0
  62. package/dist/commands/du.d.ts +3 -0
  63. package/dist/commands/du.d.ts.map +1 -0
  64. package/dist/commands/du.js +39 -0
  65. package/dist/commands/echo.d.ts.map +1 -1
  66. package/dist/commands/echo.js +2 -0
  67. package/dist/commands/env.d.ts.map +1 -1
  68. package/dist/commands/env.js +6 -14
  69. package/dist/commands/export.d.ts.map +1 -1
  70. package/dist/commands/export.js +11 -21
  71. package/dist/commands/find.d.ts +3 -0
  72. package/dist/commands/find.d.ts.map +1 -0
  73. package/dist/commands/find.js +50 -0
  74. package/dist/commands/grep.d.ts.map +1 -1
  75. package/dist/commands/grep.js +58 -35
  76. package/dist/commands/groups.d.ts +3 -0
  77. package/dist/commands/groups.d.ts.map +1 -0
  78. package/dist/commands/groups.js +12 -0
  79. package/dist/commands/gzip.d.ts +4 -0
  80. package/dist/commands/gzip.d.ts.map +1 -0
  81. package/dist/commands/gzip.js +40 -0
  82. package/dist/commands/head.d.ts +3 -0
  83. package/dist/commands/head.d.ts.map +1 -0
  84. package/dist/commands/head.js +32 -0
  85. package/dist/commands/help.d.ts +1 -1
  86. package/dist/commands/help.d.ts.map +1 -1
  87. package/dist/commands/help.js +75 -3
  88. package/dist/commands/hostname.d.ts.map +1 -1
  89. package/dist/commands/hostname.js +2 -0
  90. package/dist/commands/htop.d.ts.map +1 -1
  91. package/dist/commands/htop.js +2 -0
  92. package/dist/commands/id.d.ts +3 -0
  93. package/dist/commands/id.d.ts.map +1 -0
  94. package/dist/commands/id.js +14 -0
  95. package/dist/commands/index.d.ts +5 -2
  96. package/dist/commands/index.d.ts.map +1 -1
  97. package/dist/commands/index.js +104 -87
  98. package/dist/commands/kill.d.ts +3 -0
  99. package/dist/commands/kill.d.ts.map +1 -0
  100. package/dist/commands/kill.js +13 -0
  101. package/dist/commands/ln.d.ts +3 -0
  102. package/dist/commands/ln.d.ts.map +1 -0
  103. package/dist/commands/ln.js +44 -0
  104. package/dist/commands/ls.d.ts.map +1 -1
  105. package/dist/commands/ls.js +2 -0
  106. package/dist/commands/mkdir.d.ts.map +1 -1
  107. package/dist/commands/mkdir.js +2 -0
  108. package/dist/commands/mv.d.ts +3 -0
  109. package/dist/commands/mv.d.ts.map +1 -0
  110. package/dist/commands/mv.js +37 -0
  111. package/dist/commands/nano.d.ts.map +1 -1
  112. package/dist/commands/nano.js +2 -0
  113. package/dist/commands/neofetch.d.ts.map +1 -1
  114. package/dist/commands/neofetch.js +2 -0
  115. package/dist/commands/passwd.d.ts.map +1 -1
  116. package/dist/commands/passwd.js +2 -0
  117. package/dist/commands/ping.d.ts +3 -0
  118. package/dist/commands/ping.d.ts.map +1 -0
  119. package/dist/commands/ping.js +18 -0
  120. package/dist/commands/ps.d.ts +3 -0
  121. package/dist/commands/ps.d.ts.map +1 -0
  122. package/dist/commands/ps.js +17 -0
  123. package/dist/commands/pwd.d.ts.map +1 -1
  124. package/dist/commands/pwd.js +2 -0
  125. package/dist/commands/rm.d.ts.map +1 -1
  126. package/dist/commands/rm.js +2 -0
  127. package/dist/commands/sed.d.ts +3 -0
  128. package/dist/commands/sed.d.ts.map +1 -0
  129. package/dist/commands/sed.js +47 -0
  130. package/dist/commands/set.d.ts +3 -0
  131. package/dist/commands/set.d.ts.map +1 -1
  132. package/dist/commands/set.js +19 -46
  133. package/dist/commands/sh.d.ts +0 -1
  134. package/dist/commands/sh.d.ts.map +1 -1
  135. package/dist/commands/sh.js +228 -35
  136. package/dist/commands/sleep.d.ts +3 -0
  137. package/dist/commands/sleep.d.ts.map +1 -0
  138. package/dist/commands/sleep.js +13 -0
  139. package/dist/commands/sort.d.ts +3 -0
  140. package/dist/commands/sort.d.ts.map +1 -0
  141. package/dist/commands/sort.js +37 -0
  142. package/dist/commands/su.d.ts.map +1 -1
  143. package/dist/commands/su.js +2 -0
  144. package/dist/commands/sudo.d.ts.map +1 -1
  145. package/dist/commands/sudo.js +2 -0
  146. package/dist/commands/tail.d.ts +3 -0
  147. package/dist/commands/tail.d.ts.map +1 -0
  148. package/dist/commands/tail.js +35 -0
  149. package/dist/commands/tar.d.ts +3 -0
  150. package/dist/commands/tar.d.ts.map +1 -0
  151. package/dist/commands/tar.js +64 -0
  152. package/dist/commands/tee.d.ts +3 -0
  153. package/dist/commands/tee.d.ts.map +1 -0
  154. package/dist/commands/tee.js +29 -0
  155. package/dist/commands/touch.d.ts.map +1 -1
  156. package/dist/commands/touch.js +2 -0
  157. package/dist/commands/tr.d.ts +3 -0
  158. package/dist/commands/tr.d.ts.map +1 -0
  159. package/dist/commands/tr.js +24 -0
  160. package/dist/commands/tree.d.ts.map +1 -1
  161. package/dist/commands/tree.js +2 -0
  162. package/dist/commands/uname.d.ts +3 -0
  163. package/dist/commands/uname.d.ts.map +1 -0
  164. package/dist/commands/uname.js +21 -0
  165. package/dist/commands/uniq.d.ts +3 -0
  166. package/dist/commands/uniq.d.ts.map +1 -0
  167. package/dist/commands/uniq.js +33 -0
  168. package/dist/commands/unset.d.ts.map +1 -1
  169. package/dist/commands/unset.js +6 -10
  170. package/dist/commands/wc.d.ts +3 -0
  171. package/dist/commands/wc.d.ts.map +1 -0
  172. package/dist/commands/wc.js +50 -0
  173. package/dist/commands/wget.d.ts.map +1 -1
  174. package/dist/commands/wget.js +2 -0
  175. package/dist/commands/who.d.ts.map +1 -1
  176. package/dist/commands/who.js +2 -0
  177. package/dist/commands/whoami.d.ts.map +1 -1
  178. package/dist/commands/whoami.js +2 -0
  179. package/dist/commands/xargs.d.ts +3 -0
  180. package/dist/commands/xargs.d.ts.map +1 -0
  181. package/dist/commands/xargs.js +16 -0
  182. package/dist/index.d.ts +1 -0
  183. package/dist/index.d.ts.map +1 -1
  184. package/dist/types/commands.d.ts +13 -0
  185. package/dist/types/commands.d.ts.map +1 -1
  186. package/dist/types/pipeline.d.ts +20 -0
  187. package/dist/types/pipeline.d.ts.map +1 -1
  188. package/package.json +5 -2
  189. package/scripts/publish-package.sh +70 -0
  190. package/src/SSHMimic/exec.ts +2 -2
  191. package/src/SSHMimic/executor.ts +95 -98
  192. package/src/SSHMimic/index.ts +138 -57
  193. package/src/SSHMimic/sftp.ts +15 -0
  194. package/src/VirtualFileSystem/index.ts +464 -292
  195. package/src/VirtualShell/index.ts +4 -6
  196. package/src/VirtualShell/shell.ts +19 -2
  197. package/src/VirtualShell/shellParser.ts +202 -168
  198. package/src/VirtualUserManager/index.ts +36 -0
  199. package/src/commands/adduser.ts +2 -0
  200. package/src/commands/awk.ts +30 -0
  201. package/src/commands/base64.ts +18 -0
  202. package/src/commands/cat.ts +2 -0
  203. package/src/commands/cd.ts +2 -0
  204. package/src/commands/chmod.ts +35 -0
  205. package/src/commands/clear.ts +4 -1
  206. package/src/commands/cp.ts +78 -0
  207. package/src/commands/curl.ts +2 -0
  208. package/src/commands/cut.ts +29 -0
  209. package/src/commands/date.ts +24 -0
  210. package/src/commands/deluser.ts +2 -0
  211. package/src/commands/df.ts +18 -0
  212. package/src/commands/diff.ts +29 -0
  213. package/src/commands/du.ts +39 -0
  214. package/src/commands/echo.ts +2 -0
  215. package/src/commands/env.ts +6 -16
  216. package/src/commands/export.ts +11 -24
  217. package/src/commands/find.ts +63 -0
  218. package/src/commands/grep.ts +51 -38
  219. package/src/commands/groups.ts +14 -0
  220. package/src/commands/gzip.ts +31 -0
  221. package/src/commands/head.ts +37 -0
  222. package/src/commands/help.ts +81 -3
  223. package/src/commands/hostname.ts +2 -0
  224. package/src/commands/htop.ts +2 -0
  225. package/src/commands/id.ts +16 -0
  226. package/src/commands/index.ts +114 -133
  227. package/src/commands/kill.ts +14 -0
  228. package/src/commands/ln.ts +49 -0
  229. package/src/commands/ls.ts +2 -0
  230. package/src/commands/mkdir.ts +2 -0
  231. package/src/commands/mv.ts +45 -0
  232. package/src/commands/nano.ts +2 -0
  233. package/src/commands/neofetch.ts +2 -0
  234. package/src/commands/passwd.ts +2 -0
  235. package/src/commands/ping.ts +20 -0
  236. package/src/commands/ps.ts +19 -0
  237. package/src/commands/pwd.ts +2 -0
  238. package/src/commands/rm.ts +2 -0
  239. package/src/commands/sed.ts +45 -0
  240. package/src/commands/set.ts +19 -50
  241. package/src/commands/sh.ts +192 -43
  242. package/src/commands/sleep.ts +14 -0
  243. package/src/commands/sort.ts +37 -0
  244. package/src/commands/su.ts +2 -0
  245. package/src/commands/sudo.ts +2 -0
  246. package/src/commands/tail.ts +39 -0
  247. package/src/commands/tar.ts +58 -0
  248. package/src/commands/tee.ts +25 -0
  249. package/src/commands/touch.ts +2 -0
  250. package/src/commands/tr.ts +24 -0
  251. package/src/commands/tree.ts +2 -0
  252. package/src/commands/uname.ts +20 -0
  253. package/src/commands/uniq.ts +28 -0
  254. package/src/commands/unset.ts +5 -12
  255. package/src/commands/wc.ts +50 -0
  256. package/src/commands/wget.ts +2 -0
  257. package/src/commands/who.ts +2 -0
  258. package/src/commands/whoami.ts +2 -0
  259. package/src/commands/xargs.ts +17 -0
  260. package/src/index.ts +1 -0
  261. package/src/types/commands.ts +14 -0
  262. package/src/types/pipeline.ts +23 -0
  263. package/standalone.js +93 -55
  264. package/standalone.js.map +4 -4
  265. package/tests/bun-test-shim.ts +1 -0
  266. package/tests/sftp.test.ts +115 -191
  267. package/tests/users.test.ts +42 -88
@@ -1,13 +1,79 @@
1
1
  import { runCommand as runSingleCommand } from "../commands";
2
2
  import { resolvePath } from "../commands/helpers";
3
- import type { CommandMode, CommandResult } from "../types/commands";
4
- import type { Pipeline, PipelineCommand } from "../types/pipeline";
3
+ import type { CommandMode, CommandResult, ShellEnv } from "../types/commands";
4
+ import type { Pipeline, PipelineCommand, Script, Statement } from "../types/pipeline";
5
5
  import type { VirtualShell } from "../VirtualShell";
6
6
 
7
- /**
8
- * Execute a parsed pipeline, chaining commands and handling redirections.
9
- * Manages stdout/stderr flow between commands and file I/O.
10
- */
7
+ // ── Script executor (handles &&/||/;) ────────────────────────────────────────
8
+
9
+ export async function executeScript(
10
+ script: Script,
11
+ authUser: string,
12
+ hostname: string,
13
+ mode: CommandMode,
14
+ cwd: string,
15
+ shell: VirtualShell,
16
+ env: ShellEnv,
17
+ ): Promise<CommandResult> {
18
+ if (!script.isValid) return { stderr: script.error || "Syntax error", exitCode: 1 };
19
+
20
+ let lastResult: CommandResult = { exitCode: 0 };
21
+
22
+ for (const stmt of script.statements) {
23
+ // Decide whether to run this statement based on previous op
24
+ lastResult = await executePipeline(stmt.pipeline, authUser, hostname, mode, cwd, shell, env);
25
+ env.lastExitCode = lastResult.exitCode ?? 0;
26
+
27
+ // Propagate session-control signals
28
+ if (lastResult.closeSession || lastResult.switchUser || lastResult.nextCwd) {
29
+ break;
30
+ }
31
+ }
32
+
33
+ return lastResult;
34
+ }
35
+
36
+ /** Execute statements connected by &&/||/; */
37
+ export async function executeStatements(
38
+ statements: Statement[],
39
+ authUser: string,
40
+ hostname: string,
41
+ mode: CommandMode,
42
+ cwd: string,
43
+ shell: VirtualShell,
44
+ env: ShellEnv,
45
+ ): Promise<CommandResult> {
46
+ let last: CommandResult = { exitCode: 0 };
47
+ let i = 0;
48
+
49
+ while (i < statements.length) {
50
+ const stmt = statements[i]!;
51
+ last = await executePipeline(stmt.pipeline, authUser, hostname, mode, cwd, shell, env);
52
+ env.lastExitCode = last.exitCode ?? 0;
53
+
54
+ if (last.closeSession || last.switchUser) return last;
55
+
56
+ const op = stmt.op;
57
+ if (!op || op === ";") {
58
+ // always run next
59
+ } else if (op === "&&") {
60
+ if ((last.exitCode ?? 0) !== 0) {
61
+ // skip until next ; or end
62
+ while (i < statements.length && statements[i]?.op === "&&") i++;
63
+ }
64
+ } else if (op === "||") {
65
+ if ((last.exitCode ?? 0) === 0) {
66
+ // skip until next ; or end
67
+ while (i < statements.length && statements[i]?.op === "||") i++;
68
+ }
69
+ }
70
+ i++;
71
+ }
72
+ return last;
73
+ }
74
+
75
+ // ── Pipeline executor ─────────────────────────────────────────────────────────
76
+
11
77
  export async function executePipeline(
12
78
  pipeline: Pipeline,
13
79
  authUser: string,
@@ -15,37 +81,26 @@ export async function executePipeline(
15
81
  mode: CommandMode,
16
82
  cwd: string,
17
83
  shell: VirtualShell,
84
+ env?: ShellEnv,
18
85
  ): Promise<CommandResult> {
19
- if (pipeline.commands.length === 0) {
20
- return { exitCode: 0 };
21
- }
86
+ if (!pipeline.isValid) return { stderr: pipeline.error || "Syntax error", exitCode: 1 };
87
+ if (pipeline.commands.length === 0) return { exitCode: 0 };
88
+
89
+ const shellEnv: ShellEnv = env ?? { vars: {}, lastExitCode: 0 };
22
90
 
23
91
  if (pipeline.commands.length === 1) {
24
- // Single command with possible redirections
25
92
  return executeSingleCommandWithRedirections(
26
93
  pipeline.commands[0] as PipelineCommand,
27
- authUser,
28
- hostname,
29
- mode,
30
- cwd,
31
- shell,
94
+ authUser, hostname, mode, cwd, shell, shellEnv,
32
95
  );
33
96
  }
34
97
 
35
- // Multiple commands in a pipeline
36
98
  return executePipelineChain(
37
99
  pipeline.commands as PipelineCommand[],
38
- authUser,
39
- hostname,
40
- mode,
41
- cwd,
42
- shell,
100
+ authUser, hostname, mode, cwd, shell, shellEnv,
43
101
  );
44
102
  }
45
103
 
46
- /**
47
- * Execute a single command with input/output redirections
48
- */
49
104
  async function executeSingleCommandWithRedirections(
50
105
  cmd: PipelineCommand,
51
106
  authUser: string,
@@ -53,66 +108,37 @@ async function executeSingleCommandWithRedirections(
53
108
  mode: CommandMode,
54
109
  cwd: string,
55
110
  shell: VirtualShell,
111
+ env: ShellEnv,
56
112
  ): Promise<CommandResult> {
57
- // Prepare input if input file specified
58
113
  let stdin: string | undefined;
59
114
  if (cmd.inputFile) {
60
115
  const inputPath = resolvePath(cwd, cmd.inputFile);
61
- try {
62
- stdin = shell.vfs.readFile(inputPath);
63
- } catch {
64
- return {
65
- stderr: `cat: ${cmd.inputFile}: No such file or directory`,
66
- exitCode: 1,
67
- };
68
- }
116
+ try { stdin = shell.vfs.readFile(inputPath); }
117
+ catch { return { stderr: `${cmd.inputFile}: No such file or directory`, exitCode: 1 }; }
69
118
  }
70
119
 
71
- // Build raw input for the command
72
120
  const rawInput = [cmd.name, ...cmd.args].join(" ");
121
+ const result = await runSingleCommand(rawInput, authUser, hostname, mode, cwd, shell, stdin, env);
73
122
 
74
- // Run the command with potential input
75
- const result = await runSingleCommand(
76
- rawInput,
77
- authUser,
78
- hostname,
79
- mode,
80
- cwd,
81
- shell,
82
- stdin,
83
- );
84
-
85
- // Handle output redirection
86
123
  if (cmd.outputFile) {
87
124
  const outputPath = resolvePath(cwd, cmd.outputFile);
88
125
  const output = result.stdout || "";
89
126
  try {
90
127
  if (cmd.appendOutput) {
91
- try {
92
- const existing = shell.vfs.readFile(outputPath);
93
- shell.writeFileAsUser(authUser, outputPath, existing + output);
94
- } catch {
95
- shell.writeFileAsUser(authUser, outputPath, output);
96
- }
128
+ const existing = (() => { try { return shell.vfs.readFile(outputPath); } catch { return ""; } })();
129
+ shell.writeFileAsUser(authUser, outputPath, existing + output);
97
130
  } else {
98
131
  shell.writeFileAsUser(authUser, outputPath, output);
99
132
  }
100
133
  return { ...result, stdout: "" };
101
134
  } catch {
102
- return {
103
- ...result,
104
- stderr: `Failed to write to ${cmd.outputFile}`,
105
- exitCode: 1,
106
- };
135
+ return { ...result, stderr: `Failed to write to ${cmd.outputFile}`, exitCode: 1 };
107
136
  }
108
137
  }
109
138
 
110
139
  return result;
111
140
  }
112
141
 
113
- /**
114
- * Execute a chain of commands connected by pipes
115
- */
116
142
  async function executePipelineChain(
117
143
  commands: PipelineCommand[],
118
144
  authUser: string,
@@ -120,6 +146,7 @@ async function executePipelineChain(
120
146
  mode: CommandMode,
121
147
  cwd: string,
122
148
  shell: VirtualShell,
149
+ env: ShellEnv,
123
150
  ): Promise<CommandResult> {
124
151
  let currentOutput = "";
125
152
  let exitCode = 0;
@@ -127,66 +154,36 @@ async function executePipelineChain(
127
154
  for (let i = 0; i < commands.length; i++) {
128
155
  const cmd = commands[i] as PipelineCommand;
129
156
 
130
- // Handle input file for first command
131
157
  if (i === 0 && cmd.inputFile) {
132
158
  const inputPath = resolvePath(cwd, cmd.inputFile);
133
- try {
134
- currentOutput = shell.vfs.readFile(inputPath);
135
- } catch {
136
- return {
137
- stderr: `cat: ${cmd.inputFile}: No such file or directory`,
138
- exitCode: 1,
139
- };
140
- }
159
+ try { currentOutput = shell.vfs.readFile(inputPath); }
160
+ catch { return { stderr: `${cmd.inputFile}: No such file or directory`, exitCode: 1 }; }
141
161
  }
142
162
 
143
- // Build raw input
144
163
  const rawInput = [cmd.name, ...cmd.args].join(" ");
145
-
146
- // Create a modified context that might accept stdin
147
- // For now, we'll append input as an additional arg for commands that support it
148
- const result = await runSingleCommand(
149
- rawInput,
150
- authUser,
151
- hostname,
152
- mode,
153
- cwd,
154
- shell,
155
- currentOutput,
156
- );
157
-
164
+ const result = await runSingleCommand(rawInput, authUser, hostname, mode, cwd, shell, currentOutput, env);
158
165
  exitCode = result.exitCode ?? 0;
159
166
 
160
- // Handle output redirection (only for last command)
161
167
  if (i === commands.length - 1 && cmd.outputFile) {
162
168
  const outputPath = resolvePath(cwd, cmd.outputFile);
163
169
  const output = result.stdout || "";
164
170
  try {
165
171
  if (cmd.appendOutput) {
166
- try {
167
- const existing = shell.vfs.readFile(outputPath);
168
- shell.writeFileAsUser(authUser, outputPath, existing + output);
169
- } catch {
170
- shell.writeFileAsUser(authUser, outputPath, output);
171
- }
172
+ const existing = (() => { try { return shell.vfs.readFile(outputPath); } catch { return ""; } })();
173
+ shell.writeFileAsUser(authUser, outputPath, existing + output);
172
174
  } else {
173
175
  shell.writeFileAsUser(authUser, outputPath, output);
174
176
  }
175
177
  currentOutput = "";
176
178
  } catch {
177
- return {
178
- stderr: `Failed to write to ${cmd.outputFile}`,
179
- exitCode: 1,
180
- };
179
+ return { stderr: `Failed to write to ${cmd.outputFile}`, exitCode: 1 };
181
180
  }
182
181
  } else {
183
- // Pass output to next command
184
182
  currentOutput = result.stdout || "";
185
183
  }
186
184
 
187
- if (result.stderr && exitCode !== 0) {
188
- return { stderr: result.stderr, exitCode };
189
- }
185
+ if (result.stderr && exitCode !== 0) return { stderr: result.stderr, exitCode };
186
+ if (result.closeSession || result.switchUser) return result;
190
187
  }
191
188
 
192
189
  return { stdout: currentOutput, exitCode };
@@ -11,14 +11,31 @@ import { loadOrCreateHostKey } from "./hostKey";
11
11
  * This class is exported as `VirtualSshServer` for public API compatibility.
12
12
  * Create an instance, call {@link SshMimic.start}, and stop it with
13
13
  * {@link SshMimic.stop} when your process exits.
14
+ *
15
+ * Features:
16
+ * - Password authentication
17
+ * - Public-key authentication
18
+ * - Per-IP rate limiting / lockout for brute-force protection
19
+ * - Interactive shell sessions
20
+ * - Non-interactive exec sessions
14
21
  */
15
22
  const perf: PerfLogger = createPerfLogger("SshMimic");
16
23
 
24
+ interface RateLimitEntry {
25
+ attempts: number;
26
+ lockedUntil: number;
27
+ }
28
+
17
29
  class SshMimic extends EventEmitter {
18
30
  port: number;
19
31
  server: SshServer | null;
20
32
  private shell: VirtualShell;
21
- private shellHostname: string;
33
+
34
+ /** Max failed auth attempts before an IP is temporarily locked. */
35
+ private readonly maxAuthAttempts: number;
36
+ /** How long (ms) a locked IP must wait before retrying. */
37
+ private readonly lockoutDurationMs: number;
38
+ private readonly authAttempts = new Map<string, RateLimitEntry>();
22
39
 
23
40
  /**
24
41
  * Creates a new SSH mimic server instance.
@@ -26,24 +43,73 @@ class SshMimic extends EventEmitter {
26
43
  * @param port TCP port to bind on localhost.
27
44
  * @param hostname Virtual hostname used for the SSH ident and default shell label.
28
45
  * @param shell Optional preconfigured virtual shell instance to reuse.
46
+ * @param maxAuthAttempts Max failed attempts per IP before lockout (default: 5).
47
+ * @param lockoutDurationMs Lockout window in ms after exceeding attempts (default: 60 000).
29
48
  */
30
49
  constructor({
31
50
  port,
32
51
  hostname = "typescript-vm",
33
52
  shell = new VirtualShell(hostname),
53
+ maxAuthAttempts = 5,
54
+ lockoutDurationMs = 60_000,
34
55
  }: {
35
56
  port: number;
36
57
  hostname?: string;
37
58
  shell?: VirtualShell;
59
+ maxAuthAttempts?: number;
60
+ lockoutDurationMs?: number;
38
61
  }) {
39
62
  super();
40
63
  perf.mark("constructor");
41
64
  this.port = port;
42
- this.shellHostname = hostname;
43
65
  this.server = null;
44
66
  this.shell = shell;
67
+ this.maxAuthAttempts = maxAuthAttempts;
68
+ this.lockoutDurationMs = lockoutDurationMs;
69
+ }
70
+
71
+ // ── Rate limiting ────────────────────────────────────────────────────────
72
+
73
+ private isLockedOut(ip: string): boolean {
74
+ const entry = this.authAttempts.get(ip);
75
+ if (!entry) return false;
76
+ if (Date.now() < entry.lockedUntil) return true;
77
+ if (entry.lockedUntil > 0) {
78
+ this.authAttempts.delete(ip);
79
+ }
80
+ return false;
81
+ }
82
+
83
+ private recordFailure(ip: string): void {
84
+ const entry = this.authAttempts.get(ip) ?? { attempts: 0, lockedUntil: 0 };
85
+ entry.attempts += 1;
86
+ if (entry.attempts >= this.maxAuthAttempts) {
87
+ entry.lockedUntil = Date.now() + this.lockoutDurationMs;
88
+ this.emit("auth:lockout", { ip, until: new Date(entry.lockedUntil) });
89
+ }
90
+ this.authAttempts.set(ip, entry);
45
91
  }
46
92
 
93
+ private recordSuccess(ip: string): void {
94
+ this.authAttempts.delete(ip);
95
+ }
96
+
97
+ // ── Home directory bootstrap ─────────────────────────────────────────────
98
+
99
+ private ensureHomeDir(authUser: string): void {
100
+ const homePath = `/home/${authUser}`;
101
+ if (!this.shell.vfs.exists(homePath)) {
102
+ this.shell.vfs.mkdir(homePath, 0o755);
103
+ this.shell.vfs.writeFile(
104
+ `${homePath}/README.txt`,
105
+ `Welcome to ${this.shell.hostname}\n`,
106
+ );
107
+ void this.shell.vfs.flushMirror();
108
+ }
109
+ }
110
+
111
+ // ── Server lifecycle ─────────────────────────────────────────────────────
112
+
47
113
  /**
48
114
  * Starts server and initializes virtual filesystem, users, and handlers.
49
115
  *
@@ -54,7 +120,6 @@ class SshMimic extends EventEmitter {
54
120
  const shell = this.shell;
55
121
  const privateKey = loadOrCreateHostKey();
56
122
 
57
- // Ensure VirtualShell is fully initialized before accepting connections
58
123
  await shell.ensureInitialized();
59
124
 
60
125
  this.server = new SshServer(
@@ -70,32 +135,27 @@ class SshMimic extends EventEmitter {
70
135
  this.emit("client:connect");
71
136
 
72
137
  client.on("authentication", (ctx) => {
73
- shell;
74
- if (ctx.method === "password") {
75
- const candidateUser = ctx.username || "root";
76
- remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
138
+ const candidateUser = ctx.username || "root";
139
+ remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
77
140
 
141
+ // Rate-limit check
142
+ if (this.isLockedOut(remoteAddress)) {
143
+ this.emit("auth:failure", { username: candidateUser, remoteAddress, reason: "lockout" });
144
+ ctx.reject();
145
+ return;
146
+ }
147
+
148
+ // ── Password auth ──────────────────────────────────────
149
+ if (ctx.method === "password") {
78
150
  if (!shell.users.hasPassword(candidateUser)) {
79
151
  console.log(
80
152
  `User ${candidateUser} has no password set, allowing login without verification`,
81
153
  );
82
154
  authUser = candidateUser;
83
- sessionId = shell.users.registerSession(
84
- authUser,
85
- remoteAddress,
86
- ).id;
155
+ sessionId = shell.users.registerSession(authUser, remoteAddress).id;
156
+ this.recordSuccess(remoteAddress);
87
157
  this.emit("auth:success", { username: authUser, remoteAddress });
88
-
89
- const homePath = `/home/${authUser}`;
90
- if (!shell.vfs.exists(homePath)) {
91
- shell.vfs.mkdir(homePath, 0o755);
92
- shell.vfs.writeFile(
93
- `${homePath}/README.txt`,
94
- `Welcome to ${shell?.hostname ?? this.shellHostname}`,
95
- );
96
- void shell.vfs.flushMirror();
97
- }
98
-
158
+ this.ensureHomeDir(authUser);
99
159
  ctx.accept();
100
160
  return;
101
161
  }
@@ -105,33 +165,60 @@ class SshMimic extends EventEmitter {
105
165
  ctx.password === "" ||
106
166
  !shell.users.verifyPassword(candidateUser, ctx.password)
107
167
  ) {
108
- this.emit("auth:failure", {
109
- username: candidateUser,
110
- remoteAddress,
111
- });
168
+ this.recordFailure(remoteAddress);
169
+ this.emit("auth:failure", { username: candidateUser, remoteAddress });
112
170
  ctx.reject();
113
171
  return;
114
172
  }
115
173
 
116
174
  authUser = candidateUser;
117
175
  sessionId = shell.users.registerSession(authUser, remoteAddress).id;
176
+ this.recordSuccess(remoteAddress);
118
177
  this.emit("auth:success", { username: authUser, remoteAddress });
178
+ this.ensureHomeDir(authUser);
179
+ ctx.accept();
180
+ return;
181
+ }
119
182
 
120
- const homePath = `/home/${authUser}`;
121
- if (!shell.vfs.exists(homePath)) {
122
- shell.vfs.mkdir(homePath, 0o755);
123
- shell.vfs.writeFile(
124
- `${homePath}/README.txt`,
125
- `Welcome to ${shell?.hostname ?? this.shellHostname}`,
126
- );
127
- void shell.vfs.flushMirror();
183
+ // ── Public-key auth ────────────────────────────────────
184
+ if (ctx.method === "publickey") {
185
+ const authorizedKeys = shell.users.getAuthorizedKeys(candidateUser);
186
+ if (authorizedKeys.length === 0) {
187
+ // No keys configured — reject cleanly
188
+ ctx.reject();
189
+ return;
128
190
  }
129
191
 
130
- ctx.accept();
192
+ const incomingKey = ctx.key;
193
+ const keyMatches = authorizedKeys.some(
194
+ (k) =>
195
+ k.algo === incomingKey.algo &&
196
+ k.data.equals(incomingKey.data),
197
+ );
198
+
199
+ if (!keyMatches) {
200
+ this.recordFailure(remoteAddress);
201
+ this.emit("auth:failure", { username: candidateUser, remoteAddress, method: "publickey" });
202
+ ctx.reject();
203
+ return;
204
+ }
205
+
206
+ // Key matched — if this is a signature check step, accept
207
+ if (ctx.signature) {
208
+ authUser = candidateUser;
209
+ sessionId = shell.users.registerSession(authUser, remoteAddress).id;
210
+ this.recordSuccess(remoteAddress);
211
+ this.emit("auth:success", { username: authUser, remoteAddress, method: "publickey" });
212
+ this.ensureHomeDir(authUser);
213
+ ctx.accept();
214
+ } else {
215
+ // Key exists but no signature yet — ssh2 will call again with signature
216
+ ctx.accept();
217
+ }
131
218
  return;
132
219
  }
133
220
 
134
- ctx.reject();
221
+ ctx.reject(["password", "publickey"]);
135
222
  });
136
223
 
137
224
  client.on("close", () => {
@@ -151,35 +238,20 @@ class SshMimic extends EventEmitter {
151
238
  acceptPty();
152
239
  });
153
240
 
154
- session.on(
155
- "window-change",
156
- (_acceptChange, _rejectChange, info) => {
157
- terminalSize.cols = info?.cols ?? terminalSize.cols;
158
- terminalSize.rows = info?.rows ?? terminalSize.rows;
159
- },
160
- );
241
+ session.on("window-change", (_acceptChange, _rejectChange, info) => {
242
+ terminalSize.cols = info?.cols ?? terminalSize.cols;
243
+ terminalSize.rows = info?.rows ?? terminalSize.rows;
244
+ });
161
245
 
162
246
  session.on("shell", (acceptShell) => {
163
247
  const stream = acceptShell();
164
- shell?.startInteractiveSession(
165
- stream,
166
- authUser,
167
- sessionId,
168
- remoteAddress,
169
- terminalSize,
170
- );
248
+ shell?.startInteractiveSession(stream, authUser, sessionId, remoteAddress, terminalSize);
171
249
  });
172
250
 
173
251
  session.on("exec", (acceptExec, _rejectExec, info) => {
174
252
  const stream = acceptExec();
175
253
  if (stream) {
176
- runExec(
177
- stream,
178
- info.command.trim(),
179
- authUser,
180
- shell.hostname,
181
- shell,
182
- );
254
+ runExec(stream, info.command.trim(), authUser, shell.hostname, shell);
183
255
  }
184
256
  });
185
257
  });
@@ -209,7 +281,16 @@ class SshMimic extends EventEmitter {
209
281
  });
210
282
  }
211
283
  }
284
+
285
+ /**
286
+ * Manually clears the rate-limit record for an IP address.
287
+ * Useful in tests or admin tooling.
288
+ */
289
+ public clearLockout(ip: string): void {
290
+ this.authAttempts.delete(ip);
291
+ }
212
292
  }
213
293
 
214
294
  export { SftpMimic } from "./sftp";
215
295
  export { SshMimic };
296
+
@@ -253,6 +253,14 @@ export class SftpMimic extends EventEmitter {
253
253
  );
254
254
 
255
255
  if (ctx.method === "password") {
256
+ // If no password is set for the user, allow login without verification
257
+ if (!this.getUsers().hasPassword(candidateUser)) {
258
+ acceptSession(candidateUser);
259
+ this.emit("auth:success", { username: authUser, remoteAddress });
260
+ ctx.accept();
261
+ return;
262
+ }
263
+
256
264
  if (
257
265
  !this.getUsers().verifyPassword(candidateUser, ctx.password ?? "")
258
266
  ) {
@@ -272,6 +280,13 @@ export class SftpMimic extends EventEmitter {
272
280
 
273
281
  if (ctx.method === "keyboard-interactive") {
274
282
  const keyboardCtx = ctx as KeyboardAuthContext;
283
+ // If no password is set, accept immediately
284
+ if (!this.getUsers().hasPassword(candidateUser)) {
285
+ acceptSession(candidateUser);
286
+ this.emit("auth:success", { username: authUser, remoteAddress });
287
+ keyboardCtx.accept();
288
+ return;
289
+ }
275
290
  keyboardCtx.prompt(
276
291
  [{ prompt: "Password: ", echo: false }],
277
292
  (answers) => {