typescript-virtual-container 1.2.3 → 1.2.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 (69) hide show
  1. package/README.md +871 -1231
  2. package/benchmark-results.txt +21 -21
  3. package/biome.json +9 -0
  4. package/dist/SSHMimic/index.d.ts +19 -2
  5. package/dist/SSHMimic/index.d.ts.map +1 -1
  6. package/dist/SSHMimic/index.js +127 -15
  7. package/dist/VirtualFileSystem/index.d.ts +115 -88
  8. package/dist/VirtualFileSystem/index.d.ts.map +1 -1
  9. package/dist/VirtualFileSystem/index.js +406 -258
  10. package/dist/VirtualShell/index.d.ts +3 -4
  11. package/dist/VirtualShell/index.d.ts.map +1 -1
  12. package/dist/VirtualShell/index.js +5 -23
  13. package/dist/VirtualUserManager/index.d.ts +41 -3
  14. package/dist/VirtualUserManager/index.d.ts.map +1 -1
  15. package/dist/VirtualUserManager/index.js +83 -21
  16. package/dist/commands/chmod.d.ts +3 -0
  17. package/dist/commands/chmod.d.ts.map +1 -0
  18. package/dist/commands/chmod.js +31 -0
  19. package/dist/commands/cp.d.ts +3 -0
  20. package/dist/commands/cp.d.ts.map +1 -0
  21. package/dist/commands/cp.js +68 -0
  22. package/dist/commands/find.d.ts +3 -0
  23. package/dist/commands/find.d.ts.map +1 -0
  24. package/dist/commands/find.js +48 -0
  25. package/dist/commands/grep.d.ts.map +1 -1
  26. package/dist/commands/grep.js +61 -35
  27. package/dist/commands/head.d.ts +3 -0
  28. package/dist/commands/head.d.ts.map +1 -0
  29. package/dist/commands/head.js +30 -0
  30. package/dist/commands/index.d.ts.map +1 -1
  31. package/dist/commands/index.js +25 -35
  32. package/dist/commands/ln.d.ts +3 -0
  33. package/dist/commands/ln.d.ts.map +1 -0
  34. package/dist/commands/ln.js +42 -0
  35. package/dist/commands/mv.d.ts +3 -0
  36. package/dist/commands/mv.d.ts.map +1 -0
  37. package/dist/commands/mv.js +35 -0
  38. package/dist/commands/tail.d.ts +3 -0
  39. package/dist/commands/tail.d.ts.map +1 -0
  40. package/dist/commands/tail.js +33 -0
  41. package/dist/commands/wc.d.ts +3 -0
  42. package/dist/commands/wc.d.ts.map +1 -0
  43. package/dist/commands/wc.js +48 -0
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/standalone.js +7 -9
  47. package/package.json +7 -3
  48. package/scripts/publish-package.sh +70 -0
  49. package/src/SSHMimic/index.ts +159 -17
  50. package/src/VirtualFileSystem/index.ts +500 -280
  51. package/src/VirtualShell/index.ts +5 -33
  52. package/src/VirtualUserManager/index.ts +92 -26
  53. package/src/commands/chmod.ts +33 -0
  54. package/src/commands/cp.ts +76 -0
  55. package/src/commands/find.ts +61 -0
  56. package/src/commands/grep.ts +54 -38
  57. package/src/commands/head.ts +35 -0
  58. package/src/commands/index.ts +25 -43
  59. package/src/commands/ln.ts +47 -0
  60. package/src/commands/mv.ts +43 -0
  61. package/src/commands/tail.ts +37 -0
  62. package/src/commands/wc.ts +48 -0
  63. package/src/index.ts +1 -0
  64. package/src/standalone.ts +12 -9
  65. package/standalone.js +102 -0
  66. package/standalone.js.map +7 -0
  67. package/tests/bun-test-shim.ts +1 -0
  68. package/tests/sftp.test.ts +115 -191
  69. package/tests/users.test.ts +66 -83
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import type { CommandContext, CommandResult } from "../types/commands";
3
3
  import type { ShellStream } from "../types/streams";
4
- import VirtualFileSystem from "../VirtualFileSystem";
4
+ import VirtualFileSystem, { type VfsOptions } from "../VirtualFileSystem";
5
5
  import { VirtualUserManager } from "../VirtualUserManager";
6
6
  export interface ShellProperties {
7
7
  kernel: string;
@@ -15,7 +15,6 @@ export interface ShellProperties {
15
15
  * client API.
16
16
  */
17
17
  declare class VirtualShell extends EventEmitter {
18
- basePath: string;
19
18
  vfs: VirtualFileSystem;
20
19
  users: VirtualUserManager;
21
20
  hostname: string;
@@ -26,9 +25,9 @@ declare class VirtualShell extends EventEmitter {
26
25
  *
27
26
  * @param hostname Virtual hostname used for prompts and idents.
28
27
  * @param properties Customizable properties shown in `uname -a` and similar commands.
29
- * @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
28
+ * @param vfsOptions Optional VFS persistence options (mode, snapshotPath).
30
29
  */
31
- constructor(hostname: string, properties?: ShellProperties, basePath?: string);
30
+ constructor(hostname: string, properties?: ShellProperties, vfsOptions?: VfsOptions);
32
31
  /**
33
32
  * Ensures initialization is complete before allowing operations.
34
33
  * Call this before any authentication or command execution.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGpD,OAAO,iBAAiB,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAG3D,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACb;AAwCD;;;;;GAKG;AACH,cAAM,YAAa,SAAQ,YAAY;IACtC,QAAQ,EAAE,MAAM,CAAO;IACvB,GAAG,EAAE,iBAAiB,CAAC;IACvB,KAAK,EAAE,kBAAkB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,CAAC,WAAW,CAAgB;IAEnC;;;;;;OAMG;gBAEF,QAAQ,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,eAAe,EAC5B,QAAQ,CAAC,EAAE,MAAM;IA0BlB;;;OAGG;IACU,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/C;;;;;;OAMG;IACH,UAAU,CACT,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EAAE,EAChB,QAAQ,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,GACvE,IAAI;IASP;;;;;;OAMG;IACH,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAMrE;;;;;;;OAOG;IAEH,uBAAuB,CACtB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAC1C,IAAI;IAgBP;;;;OAIG;IACI,MAAM,IAAI,iBAAiB,GAAG,IAAI;IAIzC;;;;OAIG;IACI,QAAQ,IAAI,kBAAkB,GAAG,IAAI;IAI5C;;;;OAIG;IACI,WAAW,IAAI,MAAM;IAI5B;;;;;;OAMG;IACI,eAAe,CACrB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,MAAM,GACtB,IAAI;CAKP;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGpD,OAAO,iBAAiB,EAAE,EAAE,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAG3D,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACb;AAmBD;;;;;GAKG;AACH,cAAM,YAAa,SAAQ,YAAY;IACtC,GAAG,EAAE,iBAAiB,CAAC;IACvB,KAAK,EAAE,kBAAkB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,CAAC,WAAW,CAAgB;IAEnC;;;;;;OAMG;gBAEF,QAAQ,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,eAAe,EAC5B,UAAU,CAAC,EAAE,UAAU;IAqBxB;;;OAGG;IACU,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/C;;;;;;OAMG;IACH,UAAU,CACT,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EAAE,EAChB,QAAQ,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,GACvE,IAAI;IASP;;;;;;OAMG;IACH,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAMrE;;;;;;;OAOG;IAEH,uBAAuB,CACtB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAC1C,IAAI;IAgBP;;;;OAIG;IACI,MAAM,IAAI,iBAAiB,GAAG,IAAI;IAIzC;;;;OAIG;IACI,QAAQ,IAAI,kBAAkB,GAAG,IAAI;IAI5C;;;;OAIG;IACI,WAAW,IAAI,MAAM;IAI5B;;;;;;OAMG;IACI,eAAe,CACrB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,MAAM,GACtB,IAAI;CAKP;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
@@ -1,8 +1,7 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { EventEmitter } from "node:events";
3
2
  import { createCustomCommand, registerCommand, runCommand } from "../commands";
4
3
  import { createPerfLogger } from "../utils/perfLogger";
5
- import VirtualFileSystem from "../VirtualFileSystem";
4
+ import VirtualFileSystem, {} from "../VirtualFileSystem";
6
5
  import { VirtualUserManager } from "../VirtualUserManager";
7
6
  import { startShell } from "./shell";
8
7
  const defaultShellProperties = {
@@ -11,21 +10,6 @@ const defaultShellProperties = {
11
10
  arch: "x86_64",
12
11
  };
13
12
  const perf = createPerfLogger("VirtualShell");
14
- let cachedRootPassword = null;
15
- function resolveRootPassword() {
16
- if (cachedRootPassword) {
17
- return cachedRootPassword;
18
- }
19
- const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
20
- if (configured && configured.trim().length > 0) {
21
- cachedRootPassword = configured.trim();
22
- return cachedRootPassword;
23
- }
24
- const generated = randomBytes(18).toString("base64url");
25
- cachedRootPassword = generated;
26
- console.warn(`[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`);
27
- return generated;
28
- }
29
13
  function resolveAutoSudoForNewUsers() {
30
14
  const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
31
15
  if (!configured) {
@@ -40,7 +24,6 @@ function resolveAutoSudoForNewUsers() {
40
24
  * client API.
41
25
  */
42
26
  class VirtualShell extends EventEmitter {
43
- basePath = ".";
44
27
  vfs;
45
28
  users;
46
29
  hostname;
@@ -51,16 +34,15 @@ class VirtualShell extends EventEmitter {
51
34
  *
52
35
  * @param hostname Virtual hostname used for prompts and idents.
53
36
  * @param properties Customizable properties shown in `uname -a` and similar commands.
54
- * @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
37
+ * @param vfsOptions Optional VFS persistence options (mode, snapshotPath).
55
38
  */
56
- constructor(hostname, properties, basePath) {
39
+ constructor(hostname, properties, vfsOptions) {
57
40
  super();
58
41
  perf.mark("constructor");
59
42
  this.hostname = hostname;
60
43
  this.properties = properties || defaultShellProperties;
61
- this.basePath = basePath || ".";
62
- this.vfs = new VirtualFileSystem(this.basePath);
63
- this.users = new VirtualUserManager(this.vfs, resolveRootPassword(), resolveAutoSudoForNewUsers());
44
+ this.vfs = new VirtualFileSystem(vfsOptions ?? {});
45
+ this.users = new VirtualUserManager(this.vfs, resolveAutoSudoForNewUsers());
64
46
  // Store references to avoid TypeScript "used before assigned" errors
65
47
  const vfs = this.vfs;
66
48
  const users = this.users;
@@ -29,7 +29,6 @@ export interface VirtualActiveSession {
29
29
  */
30
30
  export declare class VirtualUserManager extends EventEmitter {
31
31
  private readonly vfs;
32
- private readonly defaultRootPassword;
33
32
  private readonly autoSudoForNewUsers;
34
33
  private static readonly recordCache;
35
34
  private static readonly fastPasswordHash;
@@ -49,7 +48,7 @@ export declare class VirtualUserManager extends EventEmitter {
49
48
  * @param defaultRootPassword Initial root password used when root is created.
50
49
  * @param autoSudoForNewUsers Whether newly created users are added to sudoers.
51
50
  */
52
- constructor(vfs: VirtualFileSystem, defaultRootPassword?: string, autoSudoForNewUsers?: boolean);
51
+ constructor(vfs: VirtualFileSystem, autoSudoForNewUsers?: boolean);
53
52
  /**
54
53
  * Loads users/sudoers from disk and ensures root account exists.
55
54
  * Also creates the current system user if not already present.
@@ -107,6 +106,13 @@ export declare class VirtualUserManager extends EventEmitter {
107
106
  * @param password Initial plaintext password.
108
107
  */
109
108
  addUser(username: string, password: string): Promise<void>;
109
+ /**
110
+ * Retrieves stored password hash for a user, or null if user does not exist.
111
+ *
112
+ * @param username Target username.
113
+ * @returns Password hash in hex encoding, or null when user is not found.
114
+ */
115
+ getPasswordHash(username: string): string | null;
110
116
  /**
111
117
  * Updates password for an existing user account.
112
118
  *
@@ -173,8 +179,40 @@ export declare class VirtualUserManager extends EventEmitter {
173
179
  private persist;
174
180
  private writeIfChanged;
175
181
  private createRecord;
176
- private hashPassword;
182
+ hasPassword(username: string): boolean;
183
+ /**
184
+ * Hashes plaintext password with per-user salt using scrypt.
185
+ *
186
+ * @param password Plaintext password.
187
+ * @returns Hex-encoded password hash.
188
+ */
189
+ hashPassword(password: string): string;
177
190
  private validateUsername;
178
191
  private validatePassword;
192
+ private readonly authorizedKeys;
193
+ /**
194
+ * Adds an SSH public key for a user, enabling public-key authentication.
195
+ *
196
+ * @param username Target user.
197
+ * @param algo Key algorithm (e.g. "ssh-rsa", "ssh-ed25519").
198
+ * @param data Raw key data as a Buffer (the base64-decoded key bytes).
199
+ */
200
+ addAuthorizedKey(username: string, algo: string, data: Buffer): void;
201
+ /**
202
+ * Removes all authorized keys for a user.
203
+ *
204
+ * @param username Target user.
205
+ */
206
+ removeAuthorizedKeys(username: string): void;
207
+ /**
208
+ * Returns the list of authorized keys for a user.
209
+ * Returns an empty array when no keys are registered.
210
+ *
211
+ * @param username Target user.
212
+ */
213
+ getAuthorizedKeys(username: string): Array<{
214
+ algo: string;
215
+ data: Buffer;
216
+ }>;
179
217
  }
180
218
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualUserManager/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualUserManager/index.ts"],"names":[],"mappings":"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"}
@@ -15,7 +15,6 @@ const perf = createPerfLogger("VirtualUserManager");
15
15
  */
16
16
  export class VirtualUserManager extends EventEmitter {
17
17
  vfs;
18
- defaultRootPassword;
19
18
  autoSudoForNewUsers;
20
19
  static recordCache = new Map();
21
20
  static fastPasswordHash = resolveFastPasswordHash();
@@ -35,10 +34,12 @@ export class VirtualUserManager extends EventEmitter {
35
34
  * @param defaultRootPassword Initial root password used when root is created.
36
35
  * @param autoSudoForNewUsers Whether newly created users are added to sudoers.
37
36
  */
38
- constructor(vfs, defaultRootPassword = "root", autoSudoForNewUsers = true) {
37
+ constructor(vfs,
38
+ // private readonly defaultRootPassword: string = process.env
39
+ // .SSH_MIMIC_ROOT_PASSWORD || "root",
40
+ autoSudoForNewUsers = true) {
39
41
  super();
40
42
  this.vfs = vfs;
41
- this.defaultRootPassword = defaultRootPassword;
42
43
  this.autoSudoForNewUsers = autoSudoForNewUsers;
43
44
  perf.mark("constructor");
44
45
  }
@@ -53,23 +54,26 @@ export class VirtualUserManager extends EventEmitter {
53
54
  this.loadQuotasFromVfs();
54
55
  let changed = false;
55
56
  if (!this.users.has("root")) {
56
- this.users.set("root", this.createRecord("root", this.defaultRootPassword));
57
+ this.users.set("root", this.createRecord("root", ""));
57
58
  changed = true;
58
59
  }
59
60
  this.sudoers.add("root");
60
61
  // Auto-create current system user for easier authentication
61
- const currentUser = process.env.USER || process.env.USERNAME;
62
- if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
63
- const userPassword = this.defaultRootPassword;
64
- this.users.set(currentUser, this.createRecord(currentUser, userPassword));
65
- this.sudoers.add(currentUser);
66
- changed = true;
67
- const homePath = `/home/${currentUser}`;
68
- if (!this.vfs.exists(homePath)) {
69
- this.vfs.mkdir(homePath, 0o755);
70
- this.vfs.writeFile(`${homePath}/README.txt`, `Welcome to the virtual environment, ${currentUser}`);
71
- }
72
- }
62
+ // const currentUser = process.env.USER || process.env.USERNAME;
63
+ // if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
64
+ // const userPassword = this.defaultRootPassword;
65
+ // this.users.set(currentUser, this.createRecord(currentUser, userPassword));
66
+ // this.sudoers.add(currentUser);
67
+ // changed = true;
68
+ // const homePath = `/home/${currentUser}`;
69
+ // if (!this.vfs.exists(homePath)) {
70
+ // this.vfs.mkdir(homePath, 0o755);
71
+ // this.vfs.writeFile(
72
+ // `${homePath}/README.txt`,
73
+ // `Welcome to the virtual environment, ${currentUser}`,
74
+ // );
75
+ // }
76
+ // }
73
77
  if (changed) {
74
78
  await this.persist();
75
79
  }
@@ -178,7 +182,7 @@ export class VirtualUserManager extends EventEmitter {
178
182
  if (!record) {
179
183
  return false;
180
184
  }
181
- return this.hashPassword(password, record.salt) === record.passwordHash;
185
+ return this.hashPassword(password) === record.passwordHash;
182
186
  }
183
187
  /**
184
188
  * Creates user, home directory, and sudo access entry.
@@ -206,6 +210,17 @@ export class VirtualUserManager extends EventEmitter {
206
210
  await this.persist();
207
211
  this.emit("user:add", { username });
208
212
  }
213
+ /**
214
+ * Retrieves stored password hash for a user, or null if user does not exist.
215
+ *
216
+ * @param username Target username.
217
+ * @returns Password hash in hex encoding, or null when user is not found.
218
+ */
219
+ getPasswordHash(username) {
220
+ perf.mark("getPasswordHash");
221
+ const record = this.users.get(username);
222
+ return record ? record.passwordHash : null;
223
+ }
209
224
  /**
210
225
  * Updates password for an existing user account.
211
226
  *
@@ -452,16 +467,30 @@ export class VirtualUserManager extends EventEmitter {
452
467
  const record = {
453
468
  username,
454
469
  salt,
455
- passwordHash: this.hashPassword(password, salt),
470
+ passwordHash: this.hashPassword(password),
456
471
  };
457
472
  VirtualUserManager.recordCache.set(cacheKey, record);
458
473
  return record;
459
474
  }
460
- hashPassword(password, salt) {
475
+ hasPassword(username) {
476
+ perf.mark("hasPassword");
477
+ if (this.getPasswordHash(username) === this.hashPassword("")) {
478
+ return false;
479
+ }
480
+ const record = this.users.get(username);
481
+ return !!record && !!record.passwordHash;
482
+ }
483
+ /**
484
+ * Hashes plaintext password with per-user salt using scrypt.
485
+ *
486
+ * @param password Plaintext password.
487
+ * @returns Hex-encoded password hash.
488
+ */
489
+ hashPassword(password) {
461
490
  if (VirtualUserManager.fastPasswordHash) {
462
- return createHash("sha256").update(`${salt}:${password}`).digest("hex");
491
+ return createHash("sha256").update(`${password}`).digest("hex");
463
492
  }
464
- return scryptSync(password, salt, 32).toString("hex");
493
+ return scryptSync(password, "", 32).toString("hex");
465
494
  }
466
495
  validateUsername(username) {
467
496
  if (!username || username.trim() === "") {
@@ -476,6 +505,39 @@ export class VirtualUserManager extends EventEmitter {
476
505
  throw new Error("invalid password");
477
506
  }
478
507
  }
508
+ authorizedKeys = new Map();
509
+ /**
510
+ * Adds an SSH public key for a user, enabling public-key authentication.
511
+ *
512
+ * @param username Target user.
513
+ * @param algo Key algorithm (e.g. "ssh-rsa", "ssh-ed25519").
514
+ * @param data Raw key data as a Buffer (the base64-decoded key bytes).
515
+ */
516
+ addAuthorizedKey(username, algo, data) {
517
+ perf.mark("addAuthorizedKey");
518
+ const keys = this.authorizedKeys.get(username) ?? [];
519
+ keys.push({ algo, data });
520
+ this.authorizedKeys.set(username, keys);
521
+ this.emit("key:add", { username, algo });
522
+ }
523
+ /**
524
+ * Removes all authorized keys for a user.
525
+ *
526
+ * @param username Target user.
527
+ */
528
+ removeAuthorizedKeys(username) {
529
+ this.authorizedKeys.delete(username);
530
+ this.emit("key:remove", { username });
531
+ }
532
+ /**
533
+ * Returns the list of authorized keys for a user.
534
+ * Returns an empty array when no keys are registered.
535
+ *
536
+ * @param username Target user.
537
+ */
538
+ getAuthorizedKeys(username) {
539
+ return this.authorizedKeys.get(username) ?? [];
540
+ }
479
541
  }
480
542
  function normalizeVfsPath(targetPath) {
481
543
  const normalized = path.posix.normalize(targetPath);
@@ -0,0 +1,3 @@
1
+ import type { ShellModule } from "../types/commands";
2
+ export declare const chmodCommand: ShellModule;
3
+ //# sourceMappingURL=chmod.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chmod.d.ts","sourceRoot":"","sources":["../../src/commands/chmod.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGrD,eAAO,MAAM,YAAY,EAAE,WA6B1B,CAAC"}
@@ -0,0 +1,31 @@
1
+ import { assertPathAccess, resolvePath } from "./helpers";
2
+ export const chmodCommand = {
3
+ name: "chmod",
4
+ params: ["<mode> <file>"],
5
+ run: ({ authUser, shell, cwd, args }) => {
6
+ const [modeArg, fileArg] = args;
7
+ if (!modeArg || !fileArg) {
8
+ return { stderr: "chmod: missing operand", exitCode: 1 };
9
+ }
10
+ const filePath = resolvePath(cwd, fileArg);
11
+ try {
12
+ assertPathAccess(authUser, filePath, "chmod");
13
+ if (!shell.vfs.exists(filePath)) {
14
+ return {
15
+ stderr: `chmod: ${fileArg}: No such file or directory`,
16
+ exitCode: 1,
17
+ };
18
+ }
19
+ const mode = parseInt(modeArg, 8);
20
+ if (Number.isNaN(mode)) {
21
+ return { stderr: `chmod: invalid mode: ${modeArg}`, exitCode: 1 };
22
+ }
23
+ shell.vfs.chmod(filePath, mode);
24
+ return { exitCode: 0 };
25
+ }
26
+ catch (err) {
27
+ const msg = err instanceof Error ? err.message : String(err);
28
+ return { stderr: `chmod: ${msg}`, exitCode: 1 };
29
+ }
30
+ },
31
+ };
@@ -0,0 +1,3 @@
1
+ import type { ShellModule } from "../types/commands";
2
+ export declare const cpCommand: ShellModule;
3
+ //# sourceMappingURL=cp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cp.d.ts","sourceRoot":"","sources":["../../src/commands/cp.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIrD,eAAO,MAAM,SAAS,EAAE,WAuEvB,CAAC"}
@@ -0,0 +1,68 @@
1
+ import { ifFlag } from "./command-helpers";
2
+ import { assertPathAccess, resolvePath } from "./helpers";
3
+ export const cpCommand = {
4
+ name: "cp",
5
+ params: ["[-r] <source> <dest>"],
6
+ run: ({ authUser, shell, cwd, args }) => {
7
+ const recursive = ifFlag(args, ["-r", "-R", "--recursive"]);
8
+ const positionals = args.filter((a) => !a.startsWith("-"));
9
+ const [srcArg, destArg] = positionals;
10
+ if (!srcArg || !destArg) {
11
+ return { stderr: "cp: missing operand", exitCode: 1 };
12
+ }
13
+ const srcPath = resolvePath(cwd, srcArg);
14
+ const destPath = resolvePath(cwd, destArg);
15
+ try {
16
+ assertPathAccess(authUser, srcPath, "cp");
17
+ assertPathAccess(authUser, destPath, "cp");
18
+ if (!shell.vfs.exists(srcPath)) {
19
+ return {
20
+ stderr: `cp: ${srcArg}: No such file or directory`,
21
+ exitCode: 1,
22
+ };
23
+ }
24
+ const srcStat = shell.vfs.stat(srcPath);
25
+ if (srcStat.type === "directory") {
26
+ if (!recursive) {
27
+ return {
28
+ stderr: `cp: ${srcArg}: is a directory (use -r)`,
29
+ exitCode: 1,
30
+ };
31
+ }
32
+ const copyDir = (from, to) => {
33
+ shell.vfs.mkdir(to, 0o755);
34
+ for (const entry of shell.vfs.list(from)) {
35
+ const fromEntry = `${from}/${entry}`;
36
+ const toEntry = `${to}/${entry}`;
37
+ const stat = shell.vfs.stat(fromEntry);
38
+ if (stat.type === "directory") {
39
+ copyDir(fromEntry, toEntry);
40
+ }
41
+ else {
42
+ const content = shell.vfs.readFileRaw(fromEntry);
43
+ shell.writeFileAsUser(authUser, toEntry, content);
44
+ }
45
+ }
46
+ };
47
+ const finalDest = shell.vfs.exists(destPath) &&
48
+ shell.vfs.stat(destPath).type === "directory"
49
+ ? `${destPath}/${srcArg.split("/").pop()}`
50
+ : destPath;
51
+ copyDir(srcPath, finalDest);
52
+ }
53
+ else {
54
+ const finalDest = shell.vfs.exists(destPath) &&
55
+ shell.vfs.stat(destPath).type === "directory"
56
+ ? `${destPath}/${srcArg.split("/").pop()}`
57
+ : destPath;
58
+ const content = shell.vfs.readFileRaw(srcPath);
59
+ shell.writeFileAsUser(authUser, finalDest, content);
60
+ }
61
+ return { exitCode: 0 };
62
+ }
63
+ catch (err) {
64
+ const msg = err instanceof Error ? err.message : String(err);
65
+ return { stderr: `cp: ${msg}`, exitCode: 1 };
66
+ }
67
+ },
68
+ };
@@ -0,0 +1,3 @@
1
+ import type { ShellModule } from "../types/commands";
2
+ export declare const findCommand: ShellModule;
3
+ //# sourceMappingURL=find.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"find.d.ts","sourceRoot":"","sources":["../../src/commands/find.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIrD,eAAO,MAAM,WAAW,EAAE,WAwDzB,CAAC"}
@@ -0,0 +1,48 @@
1
+ import { getFlag } from "./command-helpers";
2
+ import { assertPathAccess, resolvePath } from "./helpers";
3
+ export const findCommand = {
4
+ name: "find",
5
+ params: ["[path] [-name <pattern>] [-type f|d]"],
6
+ run: ({ authUser, shell, cwd, args }) => {
7
+ const namePattern = getFlag(args, ["-name"]);
8
+ const typeFilter = getFlag(args, ["-type"]);
9
+ const positionals = args.filter((a) => !a.startsWith("-") && a !== namePattern && a !== typeFilter);
10
+ const rootArg = positionals[0] ?? ".";
11
+ const rootPath = resolvePath(cwd, rootArg);
12
+ try {
13
+ assertPathAccess(authUser, rootPath, "find");
14
+ if (!shell.vfs.exists(rootPath)) {
15
+ return {
16
+ stderr: `find: ${rootArg}: No such file or directory`,
17
+ exitCode: 1,
18
+ };
19
+ }
20
+ }
21
+ catch (err) {
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ return { stderr: `find: ${msg}`, exitCode: 1 };
24
+ }
25
+ const nameRegex = namePattern
26
+ ? new RegExp(`^${namePattern.replace(/\./g, "\\.").replace(/\*/g, ".*").replace(/\?/g, ".")}$`)
27
+ : null;
28
+ const results = [];
29
+ const walk = (currentPath, display) => {
30
+ const stat = shell.vfs.stat(currentPath);
31
+ const matchesType = !typeFilter ||
32
+ (typeFilter === "f" && stat.type === "file") ||
33
+ (typeFilter === "d" && stat.type === "directory");
34
+ const matchesName = !nameRegex || nameRegex.test(currentPath.split("/").pop() ?? "");
35
+ if (matchesType && matchesName)
36
+ results.push(display);
37
+ if (stat.type === "directory") {
38
+ for (const entry of shell.vfs.list(currentPath)) {
39
+ const full = `${currentPath}/${entry}`;
40
+ const disp = `${display}/${entry}`;
41
+ walk(full, disp);
42
+ }
43
+ }
44
+ };
45
+ walk(rootPath, rootArg);
46
+ return { stdout: results.join("\n"), exitCode: 0 };
47
+ },
48
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"grep.d.ts","sourceRoot":"","sources":["../../src/commands/grep.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIrD,eAAO,MAAM,WAAW,EAAE,WA2EzB,CAAC"}
1
+ {"version":3,"file":"grep.d.ts","sourceRoot":"","sources":["../../src/commands/grep.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIrD,eAAO,MAAM,WAAW,EAAE,WA2FzB,CAAC"}
@@ -2,11 +2,15 @@ import { parseArgs } from "./command-helpers";
2
2
  import { assertPathAccess, resolvePath } from "./helpers";
3
3
  export const grepCommand = {
4
4
  name: "grep",
5
- params: ["[-i] [-v] <pattern> [file...]"],
5
+ params: ["[-i] [-v] [-n] [-r] <pattern> [file...]"],
6
6
  run: ({ authUser, shell, cwd, args, stdin }) => {
7
- const { flags, positionals } = parseArgs(args, { flags: ["-i", "-v"] });
7
+ const { flags, positionals } = parseArgs(args, {
8
+ flags: ["-i", "-v", "-n", "-r"],
9
+ });
8
10
  const caseInsensitive = flags.has("-i");
9
11
  const invertMatch = flags.has("-v");
12
+ const showLineNumbers = flags.has("-n");
13
+ const recursive = flags.has("-r");
10
14
  const pattern = positionals[0];
11
15
  const files = positionals.slice(1);
12
16
  if (!pattern) {
@@ -14,51 +18,73 @@ export const grepCommand = {
14
18
  }
15
19
  let regex;
16
20
  try {
17
- const flags = caseInsensitive ? "gmi" : "gm";
18
- regex = new RegExp(pattern, flags);
21
+ // No "g" flag avoids the stateful lastIndex problem with regex.test()
22
+ const regexFlags = caseInsensitive ? "mi" : "m";
23
+ regex = new RegExp(pattern, regexFlags);
19
24
  }
20
25
  catch {
21
26
  return { stderr: `grep: invalid regex: ${pattern}`, exitCode: 1 };
22
27
  }
23
- const results = [];
24
- if (files.length === 0) {
25
- if (!stdin) {
26
- return { stdout: "", exitCode: 1 };
27
- }
28
- const lines = stdin.split("\n");
29
- for (const line of lines) {
30
- regex.lastIndex = 0;
28
+ const matchLines = (content, prefix = "") => {
29
+ const lines = content.split("\n");
30
+ const out = [];
31
+ for (let i = 0; i < lines.length; i++) {
32
+ const line = lines[i] ?? "";
31
33
  const matches = regex.test(line);
32
34
  const shouldInclude = invertMatch ? !matches : matches;
33
35
  if (shouldInclude) {
34
- results.push(line);
36
+ const lineLabel = showLineNumbers ? `${i + 1}:` : "";
37
+ out.push(`${prefix}${lineLabel}${line}`);
35
38
  }
36
39
  }
37
- return {
38
- stdout: results.length > 0 ? results.join("\n") : "",
39
- exitCode: results.length > 0 ? 0 : 1,
40
+ return out;
41
+ };
42
+ const readPaths = (base) => {
43
+ if (!shell.vfs.exists(base))
44
+ return [];
45
+ const stat = shell.vfs.stat(base);
46
+ if (stat.type === "file")
47
+ return [base];
48
+ if (!recursive)
49
+ return [];
50
+ const paths = [];
51
+ const walk = (dir) => {
52
+ for (const entry of shell.vfs.list(dir)) {
53
+ const full = `${dir}/${entry}`;
54
+ const s = shell.vfs.stat(full);
55
+ if (s.type === "file")
56
+ paths.push(full);
57
+ else
58
+ walk(full);
59
+ }
40
60
  };
61
+ walk(base);
62
+ return paths;
63
+ };
64
+ const results = [];
65
+ if (files.length === 0) {
66
+ if (!stdin)
67
+ return { stdout: "", exitCode: 1 };
68
+ results.push(...matchLines(stdin));
41
69
  }
42
- for (const file of files) {
43
- const target = resolvePath(cwd, file);
44
- try {
45
- assertPathAccess(authUser, target, "grep");
46
- const content = shell.vfs.readFile(target);
47
- const lines = content.split("\n");
48
- for (const line of lines) {
49
- regex.lastIndex = 0;
50
- const matches = regex.test(line);
51
- const shouldInclude = invertMatch ? !matches : matches;
52
- if (shouldInclude) {
53
- results.push(line);
54
- }
70
+ else {
71
+ const resolvedPaths = files.flatMap((f) => {
72
+ const target = resolvePath(cwd, f);
73
+ return readPaths(target).map((p) => ({ file: f, path: p }));
74
+ });
75
+ for (const { file, path: filePath } of resolvedPaths) {
76
+ try {
77
+ assertPathAccess(authUser, filePath, "grep");
78
+ const content = shell.vfs.readFile(filePath);
79
+ const prefix = resolvedPaths.length > 1 ? `${file}:` : "";
80
+ results.push(...matchLines(content, prefix));
81
+ }
82
+ catch {
83
+ return {
84
+ stderr: `grep: ${file}: No such file or directory`,
85
+ exitCode: 1,
86
+ };
55
87
  }
56
- }
57
- catch {
58
- return {
59
- stderr: `grep: ${file}: No such file or directory`,
60
- exitCode: 1,
61
- };
62
88
  }
63
89
  }
64
90
  return {