typescript-virtual-container 1.1.3 → 1.1.5

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 (49) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/HONEYPOT.md +358 -0
  3. package/README.md +471 -16
  4. package/dist/SSHMimic/exec.d.ts.map +1 -1
  5. package/dist/SSHMimic/exec.js +8 -2
  6. package/dist/SSHMimic/index.d.ts +3 -1
  7. package/dist/SSHMimic/index.d.ts.map +1 -1
  8. package/dist/SSHMimic/index.js +21 -4
  9. package/dist/SSHMimic/sftp.d.ts +48 -0
  10. package/dist/SSHMimic/sftp.d.ts.map +1 -0
  11. package/dist/SSHMimic/sftp.js +595 -0
  12. package/dist/VirtualFileSystem/index.d.ts +8 -5
  13. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  14. package/dist/VirtualFileSystem/index.js +152 -154
  15. package/dist/VirtualShell/index.d.ts +8 -1
  16. package/dist/VirtualShell/index.d.ts.map +1 -1
  17. package/dist/VirtualShell/index.js +22 -5
  18. package/dist/VirtualShell/shell.d.ts.map +1 -1
  19. package/dist/VirtualShell/shell.js +7 -0
  20. package/dist/VirtualUserManager/index.d.ts +3 -1
  21. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  22. package/dist/VirtualUserManager/index.js +34 -1
  23. package/dist/commands/exit.d.ts.map +1 -1
  24. package/dist/commands/exit.js +1 -0
  25. package/dist/honeypot.d.ts +132 -0
  26. package/dist/honeypot.d.ts.map +1 -0
  27. package/dist/honeypot.js +289 -0
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -2
  31. package/dist/standalone.js +10 -1
  32. package/examples/README.md +210 -0
  33. package/examples/honeypot-audit.ts +180 -0
  34. package/examples/honeypot-export.ts +253 -0
  35. package/examples/honeypot-quickstart.ts +110 -0
  36. package/package.json +1 -1
  37. package/src/Honeypot/index.ts +422 -0
  38. package/src/SSHMimic/exec.ts +18 -12
  39. package/src/SSHMimic/index.ts +29 -8
  40. package/src/SSHMimic/sftp.ts +853 -0
  41. package/src/VirtualFileSystem/index.ts +167 -190
  42. package/src/VirtualShell/index.ts +25 -9
  43. package/src/VirtualShell/shell.ts +7 -0
  44. package/src/VirtualUserManager/index.ts +41 -3
  45. package/src/commands/exit.ts +1 -0
  46. package/src/index.ts +8 -1
  47. package/src/standalone.ts +11 -1
  48. package/tests/sftp.test.ts +319 -0
  49. package/tests/ssh-exec.test.ts +45 -0
@@ -1,4 +1,5 @@
1
- import { promises as fs } from "node:fs";
1
+ import { EventEmitter } from "node:events";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { gunzipSync, gzipSync } from "node:zlib";
4
5
  import type {
@@ -6,11 +7,7 @@ import type {
6
7
  VfsNodeStats,
7
8
  WriteFileOptions,
8
9
  } from "../types/vfs";
9
- import { archiveExists, createTarBuffer, readSnapshotFromTar } from "./archive";
10
- import type { InternalDirectoryNode, InternalNode } from "./internalTypes";
11
- import { getNode, getParentDirectory, normalizePath, splitPath } from "./path";
12
- import { applySnapshot, createSnapshot } from "./snapshot";
13
- import { renderTree } from "./tree";
10
+ import { normalizePath } from "./path";
14
11
 
15
12
  /**
16
13
  * In-memory virtual filesystem with tar.gz mirror persistence.
@@ -19,39 +16,86 @@ import { renderTree } from "./tree";
19
16
  * {@link VirtualFileSystem.restoreMirror} on startup and
20
17
  * {@link VirtualFileSystem.flushMirror} to persist pending changes.
21
18
  */
22
- class VirtualFileSystem {
23
- private readonly root: InternalDirectoryNode;
24
- private readonly archivePath: string;
25
- private dirty = false;
26
-
27
- private computeNodeUsageBytes(node: InternalNode): number {
28
- if (node.type === "file") {
29
- return node.content.length;
19
+ class VirtualFileSystem extends EventEmitter {
20
+ private readonly mirrorRoot: string;
21
+
22
+ private ensureMirrorRoot(): void {
23
+ fs.mkdirSync(this.mirrorRoot, { recursive: true, mode: 0o755 });
24
+ }
25
+
26
+ private resolveFsPath(targetPath: string): string {
27
+ const normalized = normalizePath(targetPath);
28
+ const relativePath = normalized.slice(1);
29
+ const resolved = path.resolve(this.mirrorRoot, relativePath || ".");
30
+ const relative = path.relative(this.mirrorRoot, resolved);
31
+
32
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
33
+ throw new Error(`Invalid path '${targetPath}'.`);
34
+ }
35
+
36
+ return resolved;
37
+ }
38
+
39
+ private detectGzipFile(targetPath: string): boolean {
40
+ const fd = fs.openSync(targetPath, "r");
41
+ try {
42
+ const header = Buffer.alloc(2);
43
+ const bytesRead = fs.readSync(fd, header, 0, 2, 0);
44
+ return bytesRead === 2 && header[0] === 0x1f && header[1] === 0x8b;
45
+ } finally {
46
+ fs.closeSync(fd);
47
+ }
48
+ }
49
+
50
+ private computeDiskUsageBytes(targetPath: string): number {
51
+ const stats = fs.statSync(targetPath);
52
+ if (stats.isFile()) {
53
+ return stats.size;
30
54
  }
31
55
 
32
56
  let total = 0;
33
- for (const child of node.children.values()) {
34
- total += this.computeNodeUsageBytes(child);
57
+ for (const entry of fs.readdirSync(targetPath)) {
58
+ total += this.computeDiskUsageBytes(path.join(targetPath, entry));
35
59
  }
36
60
  return total;
37
61
  }
38
62
 
63
+ private renderTreeLines(targetPath: string, label: string): string {
64
+ const lines = [label];
65
+
66
+ const walk = (currentPath: string, prefix: string): void => {
67
+ const entries = fs
68
+ .readdirSync(currentPath, { withFileTypes: true })
69
+ .map((entry) => entry.name)
70
+ .sort((left, right) => left.localeCompare(right));
71
+
72
+ for (let i = 0; i < entries.length; i += 1) {
73
+ const name = entries[i]!;
74
+ const isLast = i === entries.length - 1;
75
+ const connector = isLast ? "└── " : "├── ";
76
+ const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
77
+ const entryPath = path.join(currentPath, name);
78
+ const isDirectory = fs.statSync(entryPath).isDirectory();
79
+
80
+ lines.push(`${prefix}${connector}${name}`);
81
+ if (isDirectory) {
82
+ walk(entryPath, nextPrefix);
83
+ }
84
+ }
85
+ };
86
+
87
+ walk(targetPath, "");
88
+ return lines.join("\n");
89
+ }
90
+
39
91
  /**
40
92
  * Creates a virtual filesystem instance.
41
93
  *
42
94
  * @param baseDir Base directory used to resolve mirror archive location.
43
95
  */
44
96
  constructor(baseDir: string = process.cwd()) {
45
- const now = new Date();
46
- this.archivePath = path.resolve(baseDir, ".vfs", "mirror.tar.gz");
47
- this.root = {
48
- type: "directory",
49
- name: "",
50
- mode: 0o755,
51
- createdAt: now,
52
- updatedAt: now,
53
- children: new Map<string, InternalNode>(),
54
- };
97
+ super();
98
+ this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
55
99
  }
56
100
 
57
101
  /**
@@ -60,20 +104,7 @@ class VirtualFileSystem {
60
104
  * If archive does not exist or cannot be read, creates fresh mirror file.
61
105
  */
62
106
  public async restoreMirror(): Promise<void> {
63
- await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
64
- try {
65
- const compressed = await fs.readFile(this.archivePath);
66
- const tarBuffer = gunzipSync(compressed);
67
- const snapshot = await readSnapshotFromTar(tarBuffer);
68
- applySnapshot(this.root, snapshot);
69
- this.dirty = false;
70
- return;
71
- } catch {
72
- console.warn(
73
- `No valid mirror archive found at '${this.archivePath}'. Starting with empty filesystem.`,
74
- );
75
- await this.flushMirror();
76
- }
107
+ this.ensureMirrorRoot();
77
108
  }
78
109
 
79
110
  /**
@@ -82,16 +113,8 @@ class VirtualFileSystem {
82
113
  * No-op when nothing changed and archive already exists.
83
114
  */
84
115
  public async flushMirror(): Promise<void> {
85
- if (!this.dirty && (await archiveExists(this.archivePath))) {
86
- return;
87
- }
88
-
89
- await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
90
- const snapshotJson = JSON.stringify(createSnapshot(this.root), null, 2);
91
- const tarBuffer = await createTarBuffer(snapshotJson);
92
- const compressed = gzipSync(tarBuffer);
93
- await fs.writeFile(this.archivePath, compressed);
94
- this.dirty = false;
116
+ this.ensureMirrorRoot();
117
+ this.emit("mirror:flush");
95
118
  }
96
119
 
97
120
  /**
@@ -101,36 +124,15 @@ class VirtualFileSystem {
101
124
  * @param mode POSIX-like mode bits for new directories.
102
125
  */
103
126
  public mkdir(targetPath: string, mode: number = 0o755): void {
104
- const normalized = normalizePath(targetPath);
105
- const parts = splitPath(normalized);
106
-
107
- let current = this.root;
108
- for (const part of parts) {
109
- const existing = current.children.get(part);
110
- if (!existing) {
111
- const now = new Date();
112
- const nextDir: InternalDirectoryNode = {
113
- type: "directory",
114
- name: part,
115
- mode,
116
- createdAt: now,
117
- updatedAt: now,
118
- children: new Map<string, InternalNode>(),
119
- };
120
- current.children.set(part, nextDir);
121
- current.updatedAt = now;
122
- this.dirty = true;
123
- current = nextDir;
124
- continue;
125
- }
126
-
127
- if (existing.type !== "directory") {
128
- throw new Error(
129
- `Cannot create directory '${normalized}': '${part}' is a file.`,
130
- );
131
- }
132
- current = existing;
127
+ this.ensureMirrorRoot();
128
+ const fsPath = this.resolveFsPath(targetPath);
129
+ if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
130
+ throw new Error(
131
+ `Cannot create directory '${normalizePath(targetPath)}': path is a file.`,
132
+ );
133
133
  }
134
+ fs.mkdirSync(fsPath, { recursive: true, mode });
135
+ this.emit("dir:create", { path: normalizePath(targetPath), mode });
134
136
  }
135
137
 
136
138
  /**
@@ -147,44 +149,27 @@ class VirtualFileSystem {
147
149
  content: string | Buffer,
148
150
  options: WriteFileOptions = {},
149
151
  ): void {
152
+ this.ensureMirrorRoot();
150
153
  const normalized = normalizePath(targetPath);
151
- const { parent, name } = getParentDirectory(
152
- this.root,
153
- normalized,
154
- true,
155
- (pathToCreate) => this.mkdir(pathToCreate),
156
- );
157
- const now = new Date();
154
+ const fsPath = this.resolveFsPath(normalized);
155
+ const parentPath = path.dirname(fsPath);
156
+ fs.mkdirSync(parentPath, { recursive: true, mode: 0o755 });
158
157
 
159
158
  const rawContent = Buffer.isBuffer(content)
160
159
  ? content
161
160
  : Buffer.from(content, "utf8");
162
161
  const shouldCompress = options.compress ?? false;
163
162
  const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
164
- const existing = parent.children.get(name);
165
163
 
166
- if (existing && existing.type === "directory") {
164
+ if (fs.existsSync(fsPath) && fs.statSync(fsPath).isDirectory()) {
167
165
  throw new Error(
168
166
  `Cannot write file '${normalized}': path is a directory.`,
169
167
  );
170
168
  }
171
169
 
172
- const createdAt = existing?.type === "file" ? existing.createdAt : now;
173
- const mode =
174
- options.mode ?? (existing?.type === "file" ? existing.mode : 0o644);
175
-
176
- parent.children.set(name, {
177
- type: "file",
178
- name,
179
- mode,
180
- createdAt,
181
- updatedAt: now,
182
- content: storedContent,
183
- compressed: shouldCompress,
184
- });
185
-
186
- parent.updatedAt = now;
187
- this.dirty = true;
170
+ fs.writeFileSync(fsPath, storedContent);
171
+ fs.chmodSync(fsPath, options.mode ?? 0o644);
172
+ this.emit("file:write", { path: normalized, size: storedContent.length });
188
173
  }
189
174
 
190
175
  /**
@@ -196,12 +181,16 @@ class VirtualFileSystem {
196
181
  * @returns UTF-8 string content.
197
182
  */
198
183
  public readFile(targetPath: string): string {
199
- const node = getNode(this.root, targetPath);
200
- if (node.type !== "file") {
184
+ this.ensureMirrorRoot();
185
+ const fsPath = this.resolveFsPath(targetPath);
186
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
201
187
  throw new Error(`Cannot read '${targetPath}': not a file.`);
202
188
  }
203
189
 
204
- const raw = node.compressed ? gunzipSync(node.content) : node.content;
190
+ const stored = fs.readFileSync(fsPath);
191
+ const raw = this.detectGzipFile(fsPath) ? gunzipSync(stored) : stored;
192
+ const normalized = normalizePath(targetPath);
193
+ this.emit("file:read", { path: normalized, size: raw.length });
205
194
  return raw.toString("utf8");
206
195
  }
207
196
 
@@ -213,8 +202,8 @@ class VirtualFileSystem {
213
202
  */
214
203
  public exists(targetPath: string): boolean {
215
204
  try {
216
- getNode(this.root, targetPath);
217
- return true;
205
+ const fsPath = this.resolveFsPath(targetPath);
206
+ return fs.existsSync(fsPath);
218
207
  } catch {
219
208
  return false;
220
209
  }
@@ -227,10 +216,11 @@ class VirtualFileSystem {
227
216
  * @param mode New POSIX-like mode.
228
217
  */
229
218
  public chmod(targetPath: string, mode: number): void {
230
- const node = getNode(this.root, targetPath);
231
- node.mode = mode;
232
- node.updatedAt = new Date();
233
- this.dirty = true;
219
+ const fsPath = this.resolveFsPath(targetPath);
220
+ if (!fs.existsSync(fsPath)) {
221
+ throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
222
+ }
223
+ fs.chmodSync(fsPath, mode);
234
224
  }
235
225
 
236
226
  /**
@@ -240,30 +230,39 @@ class VirtualFileSystem {
240
230
  * @returns Typed stat object based on node type.
241
231
  */
242
232
  public stat(targetPath: string): VfsNodeStats {
233
+ this.ensureMirrorRoot();
243
234
  const normalized = normalizePath(targetPath);
244
- const node = getNode(this.root, normalized);
235
+ const fsPath = this.resolveFsPath(normalized);
236
+
237
+ if (!fs.existsSync(fsPath)) {
238
+ throw new Error(`Path '${normalized}' does not exist.`);
239
+ }
245
240
 
246
- if (node.type === "file") {
241
+ const stats = fs.statSync(fsPath);
242
+ const mode = stats.mode & 0o777;
243
+ const name = normalized === "/" ? "" : path.posix.basename(normalized);
244
+
245
+ if (stats.isFile()) {
247
246
  return {
248
247
  type: "file",
249
- name: node.name,
248
+ name,
250
249
  path: normalized,
251
- mode: node.mode,
252
- createdAt: node.createdAt,
253
- updatedAt: node.updatedAt,
254
- compressed: node.compressed,
255
- size: node.content.length,
250
+ mode,
251
+ createdAt: stats.birthtime,
252
+ updatedAt: stats.mtime,
253
+ compressed: this.detectGzipFile(fsPath),
254
+ size: stats.size,
256
255
  };
257
256
  }
258
257
 
259
258
  return {
260
259
  type: "directory",
261
- name: node.name,
260
+ name,
262
261
  path: normalized,
263
- mode: node.mode,
264
- createdAt: node.createdAt,
265
- updatedAt: node.updatedAt,
266
- childrenCount: node.children.size,
262
+ mode,
263
+ createdAt: stats.birthtime,
264
+ updatedAt: stats.mtime,
265
+ childrenCount: fs.readdirSync(fsPath).length,
267
266
  };
268
267
  }
269
268
 
@@ -274,12 +273,12 @@ class VirtualFileSystem {
274
273
  * @returns Sorted child names.
275
274
  */
276
275
  public list(dirPath: string = "/"): string[] {
277
- const node = getNode(this.root, dirPath);
278
- if (node.type !== "directory") {
276
+ const fsPath = this.resolveFsPath(dirPath);
277
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
279
278
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
280
279
  }
281
280
 
282
- return Array.from(node.children.keys()).sort();
281
+ return fs.readdirSync(fsPath).sort();
283
282
  }
284
283
 
285
284
  /**
@@ -289,14 +288,14 @@ class VirtualFileSystem {
289
288
  * @returns Multi-line tree string.
290
289
  */
291
290
  public tree(dirPath: string = "/"): string {
292
- const node = getNode(this.root, dirPath);
293
- if (node.type !== "directory") {
291
+ const fsPath = this.resolveFsPath(dirPath);
292
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
294
293
  throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
295
294
  }
296
295
 
297
296
  const rootLabel =
298
297
  dirPath === "/" ? "/" : path.posix.basename(normalizePath(dirPath));
299
- return renderTree(node, rootLabel);
298
+ return this.renderTreeLines(fsPath, rootLabel);
300
299
  }
301
300
 
302
301
  /**
@@ -309,8 +308,11 @@ class VirtualFileSystem {
309
308
  * @returns Total byte usage for file content under target path.
310
309
  */
311
310
  public getUsageBytes(targetPath: string = "/"): number {
312
- const node = getNode(this.root, targetPath);
313
- return this.computeNodeUsageBytes(node);
311
+ const fsPath = this.resolveFsPath(targetPath);
312
+ if (!fs.existsSync(fsPath)) {
313
+ throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
314
+ }
315
+ return this.computeDiskUsageBytes(fsPath);
314
316
  }
315
317
 
316
318
  /**
@@ -319,16 +321,14 @@ class VirtualFileSystem {
319
321
  * @param targetPath Path to file.
320
322
  */
321
323
  public compressFile(targetPath: string): void {
322
- const node = getNode(this.root, targetPath);
323
- if (node.type !== "file") {
324
+ const fsPath = this.resolveFsPath(targetPath);
325
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
324
326
  throw new Error(`Cannot compress '${targetPath}': not a file.`);
325
327
  }
326
328
 
327
- if (!node.compressed) {
328
- node.content = gzipSync(node.content);
329
- node.compressed = true;
330
- node.updatedAt = new Date();
331
- this.dirty = true;
329
+ if (!this.detectGzipFile(fsPath)) {
330
+ const content = fs.readFileSync(fsPath);
331
+ fs.writeFileSync(fsPath, gzipSync(content));
332
332
  }
333
333
  }
334
334
 
@@ -338,16 +338,14 @@ class VirtualFileSystem {
338
338
  * @param targetPath Path to file.
339
339
  */
340
340
  public decompressFile(targetPath: string): void {
341
- const node = getNode(this.root, targetPath);
342
- if (node.type !== "file") {
341
+ const fsPath = this.resolveFsPath(targetPath);
342
+ if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
343
343
  throw new Error(`Cannot decompress '${targetPath}': not a file.`);
344
344
  }
345
345
 
346
- if (node.compressed) {
347
- node.content = gunzipSync(node.content);
348
- node.compressed = false;
349
- node.updatedAt = new Date();
350
- this.dirty = true;
346
+ if (this.detectGzipFile(fsPath)) {
347
+ const content = fs.readFileSync(fsPath);
348
+ fs.writeFileSync(fsPath, gunzipSync(content));
351
349
  }
352
350
  }
353
351
 
@@ -362,32 +360,27 @@ class VirtualFileSystem {
362
360
  if (normalized === "/") {
363
361
  throw new Error("Cannot remove root directory.");
364
362
  }
363
+ const fsPath = this.resolveFsPath(normalized);
365
364
 
366
- const { parent, name } = getParentDirectory(
367
- this.root,
368
- normalized,
369
- false,
370
- () => undefined,
371
- );
372
- const node = parent.children.get(name);
373
-
374
- if (!node) {
365
+ if (!fs.existsSync(fsPath)) {
375
366
  throw new Error(`Path '${normalized}' does not exist.`);
376
367
  }
377
368
 
378
- if (
379
- node.type === "directory" &&
380
- node.children.size > 0 &&
381
- !options.recursive
382
- ) {
383
- throw new Error(
384
- `Directory '${normalized}' is not empty. Use recursive option.`,
385
- );
369
+ const stats = fs.statSync(fsPath);
370
+ if (stats.isDirectory() && !options.recursive) {
371
+ const entries = fs.readdirSync(fsPath);
372
+ if (entries.length > 0) {
373
+ throw new Error(
374
+ `Directory '${normalized}' is not empty. Use recursive option.`,
375
+ );
376
+ }
386
377
  }
387
378
 
388
- parent.children.delete(name);
389
- parent.updatedAt = new Date();
390
- this.dirty = true;
379
+ if (stats.isDirectory()) {
380
+ fs.rmSync(fsPath, { recursive: options.recursive ?? false });
381
+ } else {
382
+ fs.rmSync(fsPath);
383
+ }
391
384
  }
392
385
 
393
386
  /**
@@ -404,35 +397,19 @@ class VirtualFileSystem {
404
397
  throw new Error("Cannot move root directory.");
405
398
  }
406
399
 
407
- const { parent: fromParent, name: fromName } = getParentDirectory(
408
- this.root,
409
- fromNormalized,
410
- false,
411
- () => undefined,
412
- );
413
- const node = fromParent.children.get(fromName);
400
+ const fromFsPath = this.resolveFsPath(fromNormalized);
401
+ const toFsPath = this.resolveFsPath(toNormalized);
414
402
 
415
- if (!node) {
403
+ if (!fs.existsSync(fromFsPath)) {
416
404
  throw new Error(`Path '${fromNormalized}' does not exist.`);
417
405
  }
418
406
 
419
- const { parent: toParent, name: toName } = getParentDirectory(
420
- this.root,
421
- toNormalized,
422
- true,
423
- (pathToCreate) => this.mkdir(pathToCreate),
424
- );
425
- if (toParent.children.has(toName)) {
407
+ if (fs.existsSync(toFsPath)) {
426
408
  throw new Error(`Destination '${toNormalized}' already exists.`);
427
409
  }
428
410
 
429
- fromParent.children.delete(fromName);
430
- node.name = toName;
431
- node.updatedAt = new Date();
432
- toParent.children.set(toName, node);
433
- fromParent.updatedAt = new Date();
434
- toParent.updatedAt = new Date();
435
- this.dirty = true;
411
+ fs.mkdirSync(path.dirname(toFsPath), { recursive: true, mode: 0o755 });
412
+ fs.renameSync(fromFsPath, toFsPath);
436
413
  }
437
414
  }
438
415
 
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
2
3
  import { createCustomCommand, registerCommand, runCommand } from "../commands";
3
4
  import type { CommandContext, CommandResult } from "../types/commands";
4
5
  import type { ShellStream } from "../types/streams";
@@ -46,12 +47,13 @@ function resolveAutoSudoForNewUsers(): boolean {
46
47
  * Instances are used both by the SSH server facade and by the programmatic
47
48
  * client API.
48
49
  */
49
- class VirtualShell {
50
+ class VirtualShell extends EventEmitter {
50
51
  basePath: string = ".";
51
52
  vfs: VirtualFileSystem;
52
53
  users: VirtualUserManager;
53
54
  hostname: string;
54
55
  properties: ShellProperties;
56
+ private initialized: Promise<void>;
55
57
 
56
58
  /**
57
59
  * Creates a new virtual shell instance.
@@ -65,6 +67,7 @@ class VirtualShell {
65
67
  properties?: ShellProperties,
66
68
  basePath?: string,
67
69
  ) {
70
+ super();
68
71
  this.hostname = hostname;
69
72
  this.properties = properties || defaultShellProperties;
70
73
  this.basePath = basePath || ".";
@@ -74,14 +77,25 @@ class VirtualShell {
74
77
  resolveRootPassword(),
75
78
  resolveAutoSudoForNewUsers(),
76
79
  );
77
- this.vfs.restoreMirror().then(() => {
78
- this.users = new VirtualUserManager(
79
- this.vfs,
80
- resolveRootPassword(),
81
- resolveAutoSudoForNewUsers(),
82
- );
83
- this.users.initialize();
84
- });
80
+
81
+ // Store references to avoid TypeScript "used before assigned" errors
82
+ const vfs = this.vfs;
83
+ const users = this.users;
84
+
85
+ // Initialize both VFS mirror and users, ensuring all is ready before auth
86
+ this.initialized = (async () => {
87
+ await vfs.restoreMirror();
88
+ await users.initialize();
89
+ this.emit("initialized");
90
+ })();
91
+ }
92
+
93
+ /**
94
+ * Ensures initialization is complete before allowing operations.
95
+ * Call this before any authentication or command execution.
96
+ */
97
+ public async ensureInitialized(): Promise<void> {
98
+ await this.initialized;
85
99
  }
86
100
 
87
101
  /**
@@ -113,6 +127,7 @@ class VirtualShell {
113
127
  */
114
128
  executeCommand(rawInput: string, authUser: string, cwd: string): void {
115
129
  runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
130
+ this.emit("command", { command: rawInput, user: authUser, cwd });
116
131
  }
117
132
 
118
133
  /**
@@ -132,6 +147,7 @@ class VirtualShell {
132
147
  terminalSize: { cols: number; rows: number },
133
148
  ): void {
134
149
  // Interactive shell logic
150
+ this.emit("session:start", { user: authUser, sessionId, remoteAddress });
135
151
  startShell(
136
152
  this.properties,
137
153
  stream,
@@ -467,6 +467,13 @@ export function startShell(
467
467
  const ch = input[i]!;
468
468
 
469
469
  if (ch === "\u0004") {
470
+ lineBuffer = "";
471
+ cursorPos = 0;
472
+ historyIndex = null;
473
+ historyDraft = "";
474
+ stream.write("bye\r\n");
475
+ pushHistory("bye");
476
+ await shell.vfs.flushMirror();
470
477
  stream.write("logout\r\n");
471
478
  stream.exit(0);
472
479
  stream.end();