typescript-virtual-container 1.2.0 → 1.2.1

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.
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { Server as SshServer } from "ssh2";
3
3
  import { VirtualShell } from "../VirtualShell";
4
+ import { createPerfLogger, type PerfLogger } from "../utils/perfLogger";
4
5
  import { runExec } from "./exec";
5
6
  import { loadOrCreateHostKey } from "./hostKey";
6
7
 
@@ -11,6 +12,8 @@ import { loadOrCreateHostKey } from "./hostKey";
11
12
  * Create an instance, call {@link SshMimic.start}, and stop it with
12
13
  * {@link SshMimic.stop} when your process exits.
13
14
  */
15
+ const perf: PerfLogger = createPerfLogger("SshMimic");
16
+
14
17
  class SshMimic extends EventEmitter {
15
18
  port: number;
16
19
  server: SshServer | null;
@@ -34,6 +37,7 @@ class SshMimic extends EventEmitter {
34
37
  shell?: VirtualShell;
35
38
  }) {
36
39
  super();
40
+ perf.mark("constructor");
37
41
  this.port = port;
38
42
  this.shellHostname = hostname;
39
43
  this.server = null;
@@ -46,6 +50,7 @@ class SshMimic extends EventEmitter {
46
50
  * @returns Promise resolved with bound listening port.
47
51
  */
48
52
  public async start(): Promise<number> {
53
+ perf.mark("start");
49
54
  const shell = this.shell;
50
55
  const privateKey = loadOrCreateHostKey();
51
56
 
@@ -169,6 +174,7 @@ class SshMimic extends EventEmitter {
169
174
  * Stops server if running.
170
175
  */
171
176
  public stop(): void {
177
+ perf.mark("stop");
172
178
  if (this.server) {
173
179
  this.server.close(() => {
174
180
  console.log("SSH Mimic stopped");
@@ -3,10 +3,12 @@ import { EventEmitter } from "node:events";
3
3
  import * as path from "node:path";
4
4
  import type { AuthenticationType, KeyboardAuthContext } from "ssh2";
5
5
  import { Server as SshServer } from "ssh2";
6
+ import type { VfsNodeStats } from "../types/vfs";
7
+ import type { PerfLogger } from "../utils/perfLogger";
8
+ import { createPerfLogger } from "../utils/perfLogger";
6
9
  import type VirtualFileSystem from "../VirtualFileSystem";
7
10
  import { VirtualShell } from "../VirtualShell";
8
11
  import type { VirtualUserManager } from "../VirtualUserManager";
9
- import type { VfsNodeStats } from "../types/vfs";
10
12
  import { loadOrCreateHostKey } from "./hostKey";
11
13
 
12
14
  const SFTP_STATUS_CODE = {
@@ -30,6 +32,8 @@ const OPEN_MODE = {
30
32
  EXCL: 0x00000020,
31
33
  };
32
34
 
35
+ const perf: PerfLogger = createPerfLogger("SftpMimic");
36
+
33
37
  interface SftpFileHandle {
34
38
  type: "file";
35
39
  path: string;
@@ -154,6 +158,7 @@ export class SftpMimic extends EventEmitter {
154
158
  users,
155
159
  }: SftpMimicOptions) {
156
160
  super();
161
+ perf.mark("constructor");
157
162
  this.port = port;
158
163
  this.server = null;
159
164
  this.hostname = hostname;
@@ -184,6 +189,7 @@ export class SftpMimic extends EventEmitter {
184
189
  }
185
190
 
186
191
  public async start(): Promise<number> {
192
+ perf.mark("start");
187
193
  const privateKey = loadOrCreateHostKey();
188
194
 
189
195
  // Ensure VirtualShell is fully initialized before accepting connections
@@ -336,6 +342,7 @@ export class SftpMimic extends EventEmitter {
336
342
  }
337
343
 
338
344
  public stop(): void {
345
+ perf.mark("stop");
339
346
  if (this.server) {
340
347
  this.server.close(() => {
341
348
  console.log("SFTP Mimic stopped");
@@ -7,6 +7,8 @@ import type {
7
7
  VfsNodeStats,
8
8
  WriteFileOptions,
9
9
  } from "../types/vfs";
10
+ import type { PerfLogger } from "../utils/perfLogger";
11
+ import { createPerfLogger } from "../utils/perfLogger";
10
12
  import { normalizePath } from "./path";
11
13
 
12
14
  /**
@@ -16,6 +18,8 @@ import { normalizePath } from "./path";
16
18
  * {@link VirtualFileSystem.restoreMirror} on startup and
17
19
  * {@link VirtualFileSystem.flushMirror} to persist pending changes.
18
20
  */
21
+ const perf: PerfLogger = createPerfLogger("VirtualFileSystem");
22
+
19
23
  class VirtualFileSystem extends EventEmitter {
20
24
  private readonly mirrorRoot: string;
21
25
 
@@ -95,6 +99,7 @@ class VirtualFileSystem extends EventEmitter {
95
99
  */
96
100
  constructor(baseDir: string = process.cwd()) {
97
101
  super();
102
+ perf.mark("constructor");
98
103
  this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
99
104
  }
100
105
 
@@ -104,6 +109,7 @@ class VirtualFileSystem extends EventEmitter {
104
109
  * If archive does not exist or cannot be read, creates fresh mirror file.
105
110
  */
106
111
  public async restoreMirror(): Promise<void> {
112
+ perf.mark("restoreMirror");
107
113
  this.ensureMirrorRoot();
108
114
  }
109
115
 
@@ -113,6 +119,7 @@ class VirtualFileSystem extends EventEmitter {
113
119
  * No-op when nothing changed and archive already exists.
114
120
  */
115
121
  public async flushMirror(): Promise<void> {
122
+ perf.mark("flushMirror");
116
123
  this.ensureMirrorRoot();
117
124
  this.emit("mirror:flush");
118
125
  }
@@ -124,6 +131,7 @@ class VirtualFileSystem extends EventEmitter {
124
131
  * @param mode POSIX-like mode bits for new directories.
125
132
  */
126
133
  public mkdir(targetPath: string, mode: number = 0o755): void {
134
+ perf.mark("mkdir");
127
135
  this.ensureMirrorRoot();
128
136
  const fsPath = this.resolveFsPath(targetPath);
129
137
  if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
@@ -149,6 +157,7 @@ class VirtualFileSystem extends EventEmitter {
149
157
  content: string | Buffer,
150
158
  options: WriteFileOptions = {},
151
159
  ): void {
160
+ perf.mark("writeFile");
152
161
  this.ensureMirrorRoot();
153
162
  const normalized = normalizePath(targetPath);
154
163
  const fsPath = this.resolveFsPath(normalized);
@@ -181,6 +190,7 @@ class VirtualFileSystem extends EventEmitter {
181
190
  * @returns UTF-8 string content.
182
191
  */
183
192
  public readFile(targetPath: string): string {
193
+ perf.mark("readFile");
184
194
  this.ensureMirrorRoot();
185
195
  const fsPath = this.resolveFsPath(targetPath);
186
196
  if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
@@ -201,6 +211,7 @@ class VirtualFileSystem extends EventEmitter {
201
211
  * @returns True when file or directory exists.
202
212
  */
203
213
  public exists(targetPath: string): boolean {
214
+ perf.mark("exists");
204
215
  try {
205
216
  const fsPath = this.resolveFsPath(targetPath);
206
217
  return fs.existsSync(fsPath);
@@ -216,6 +227,7 @@ class VirtualFileSystem extends EventEmitter {
216
227
  * @param mode New POSIX-like mode.
217
228
  */
218
229
  public chmod(targetPath: string, mode: number): void {
230
+ perf.mark("chmod");
219
231
  const fsPath = this.resolveFsPath(targetPath);
220
232
  if (!fs.existsSync(fsPath)) {
221
233
  throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
@@ -230,6 +242,7 @@ class VirtualFileSystem extends EventEmitter {
230
242
  * @returns Typed stat object based on node type.
231
243
  */
232
244
  public stat(targetPath: string): VfsNodeStats {
245
+ perf.mark("stat");
233
246
  this.ensureMirrorRoot();
234
247
  const normalized = normalizePath(targetPath);
235
248
  const fsPath = this.resolveFsPath(normalized);
@@ -273,6 +286,7 @@ class VirtualFileSystem extends EventEmitter {
273
286
  * @returns Sorted child names.
274
287
  */
275
288
  public list(dirPath: string = "/"): string[] {
289
+ perf.mark("list");
276
290
  const fsPath = this.resolveFsPath(dirPath);
277
291
  if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
278
292
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
@@ -288,6 +302,7 @@ class VirtualFileSystem extends EventEmitter {
288
302
  * @returns Multi-line tree string.
289
303
  */
290
304
  public tree(dirPath: string = "/"): string {
305
+ perf.mark("tree");
291
306
  const fsPath = this.resolveFsPath(dirPath);
292
307
  if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
293
308
  throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
@@ -308,6 +323,7 @@ class VirtualFileSystem extends EventEmitter {
308
323
  * @returns Total byte usage for file content under target path.
309
324
  */
310
325
  public getUsageBytes(targetPath: string = "/"): number {
326
+ perf.mark("getUsageBytes");
311
327
  const fsPath = this.resolveFsPath(targetPath);
312
328
  if (!fs.existsSync(fsPath)) {
313
329
  throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
@@ -321,6 +337,7 @@ class VirtualFileSystem extends EventEmitter {
321
337
  * @param targetPath Path to file.
322
338
  */
323
339
  public compressFile(targetPath: string): void {
340
+ perf.mark("compressFile");
324
341
  const fsPath = this.resolveFsPath(targetPath);
325
342
  if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
326
343
  throw new Error(`Cannot compress '${targetPath}': not a file.`);
@@ -338,6 +355,7 @@ class VirtualFileSystem extends EventEmitter {
338
355
  * @param targetPath Path to file.
339
356
  */
340
357
  public decompressFile(targetPath: string): void {
358
+ perf.mark("decompressFile");
341
359
  const fsPath = this.resolveFsPath(targetPath);
342
360
  if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
343
361
  throw new Error(`Cannot decompress '${targetPath}': not a file.`);
@@ -356,6 +374,7 @@ class VirtualFileSystem extends EventEmitter {
356
374
  * @param options Removal options, including recursive delete.
357
375
  */
358
376
  public remove(targetPath: string, options: RemoveOptions = {}): void {
377
+ perf.mark("remove");
359
378
  const normalized = normalizePath(targetPath);
360
379
  if (normalized === "/") {
361
380
  throw new Error("Cannot remove root directory.");
@@ -390,6 +409,7 @@ class VirtualFileSystem extends EventEmitter {
390
409
  * @param toPath Destination path.
391
410
  */
392
411
  public move(fromPath: string, toPath: string): void {
412
+ perf.mark("move");
393
413
  const fromNormalized = normalizePath(fromPath);
394
414
  const toNormalized = normalizePath(toPath);
395
415
 
@@ -3,6 +3,8 @@ import { EventEmitter } from "node:events";
3
3
  import { createCustomCommand, registerCommand, runCommand } from "../commands";
4
4
  import type { CommandContext, CommandResult } from "../types/commands";
5
5
  import type { ShellStream } from "../types/streams";
6
+ import type { PerfLogger } from "../utils/perfLogger";
7
+ import { createPerfLogger } from "../utils/perfLogger";
6
8
  import VirtualFileSystem from "../VirtualFileSystem";
7
9
  import { VirtualUserManager } from "../VirtualUserManager";
8
10
  import { startShell } from "./shell";
@@ -19,13 +21,23 @@ const defaultShellProperties: ShellProperties = {
19
21
  arch: "x86_64",
20
22
  };
21
23
 
24
+ const perf: PerfLogger = createPerfLogger("VirtualShell");
25
+
26
+ let cachedRootPassword: string | null = null;
27
+
22
28
  function resolveRootPassword(): string {
29
+ if (cachedRootPassword) {
30
+ return cachedRootPassword;
31
+ }
32
+
23
33
  const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
24
34
  if (configured && configured.trim().length > 0) {
25
- return configured;
35
+ cachedRootPassword = configured.trim();
36
+ return cachedRootPassword;
26
37
  }
27
38
 
28
39
  const generated = randomBytes(18).toString("base64url");
40
+ cachedRootPassword = generated;
29
41
  console.warn(
30
42
  `[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
31
43
  );
@@ -68,6 +80,7 @@ class VirtualShell extends EventEmitter {
68
80
  basePath?: string,
69
81
  ) {
70
82
  super();
83
+ perf.mark("constructor");
71
84
  this.hostname = hostname;
72
85
  this.properties = properties || defaultShellProperties;
73
86
  this.basePath = basePath || ".";
@@ -95,6 +108,7 @@ class VirtualShell extends EventEmitter {
95
108
  * Call this before any authentication or command execution.
96
109
  */
97
110
  public async ensureInitialized(): Promise<void> {
111
+ perf.mark("ensureInitialized");
98
112
  await this.initialized;
99
113
  }
100
114
 
@@ -126,6 +140,7 @@ class VirtualShell extends EventEmitter {
126
140
  * @param cwd
127
141
  */
128
142
  executeCommand(rawInput: string, authUser: string, cwd: string): void {
143
+ perf.mark("executeCommand");
129
144
  runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
130
145
  this.emit("command", { command: rawInput, user: authUser, cwd });
131
146
  }
@@ -146,6 +161,7 @@ class VirtualShell extends EventEmitter {
146
161
  remoteAddress: string,
147
162
  terminalSize: { cols: number; rows: number },
148
163
  ): void {
164
+ perf.mark("startInteractiveSession");
149
165
  // Interactive shell logic
150
166
  this.emit("session:start", { user: authUser, sessionId, remoteAddress });
151
167
  startShell(
@@ -199,6 +215,7 @@ class VirtualShell extends EventEmitter {
199
215
  targetPath: string,
200
216
  content: string | Buffer,
201
217
  ): void {
218
+ perf.mark("writeFileAsUser");
202
219
  this.users.assertWriteWithinQuota(authUser, targetPath, content);
203
220
  this.vfs.writeFile(targetPath, content);
204
221
  }
@@ -1,6 +1,8 @@
1
- import { randomBytes, randomUUID, scryptSync } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, scryptSync } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
3
  import * as path from "node:path";
4
+ import type { PerfLogger } from "../utils/perfLogger";
5
+ import { createPerfLogger } from "../utils/perfLogger";
4
6
  import type VirtualFileSystem from "../VirtualFileSystem";
5
7
 
6
8
  /** Persisted virtual user credential record. */
@@ -27,12 +29,24 @@ export interface VirtualActiveSession {
27
29
  startedAt: string;
28
30
  }
29
31
 
32
+ function resolveFastPasswordHash(): boolean {
33
+ const configured = process.env.SSH_MIMIC_FAST_PASSWORD_HASH;
34
+ return (
35
+ !!configured &&
36
+ !["0", "false", "no", "off"].includes(configured.toLowerCase())
37
+ );
38
+ }
39
+
40
+ const perf: PerfLogger = createPerfLogger("VirtualUserManager");
41
+
30
42
  /**
31
43
  * Persistent user, sudoers, and active-session manager for the shell runtime.
32
44
  *
33
- * Passwords are hashed with scrypt and stored in the backing virtual filesystem.
45
+ * Passwords are hashed with scrypt by default and stored in the backing virtual filesystem.
34
46
  */
35
47
  export class VirtualUserManager extends EventEmitter {
48
+ private static readonly recordCache = new Map<string, VirtualUserRecord>();
49
+ private static readonly fastPasswordHash = resolveFastPasswordHash();
36
50
  private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
37
51
  private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
38
52
  private readonly quotasPath = "/virtual-env-js/.auth/quotas";
@@ -56,6 +70,7 @@ export class VirtualUserManager extends EventEmitter {
56
70
  private readonly autoSudoForNewUsers: boolean = true,
57
71
  ) {
58
72
  super();
73
+ perf.mark("constructor");
59
74
  }
60
75
 
61
76
  /**
@@ -63,23 +78,30 @@ export class VirtualUserManager extends EventEmitter {
63
78
  * Also creates the current system user if not already present.
64
79
  */
65
80
  public async initialize(): Promise<void> {
81
+ perf.mark("initialize");
66
82
  this.loadFromVfs();
67
83
  this.loadSudoersFromVfs();
68
84
  this.loadQuotasFromVfs();
69
85
 
70
- this.users.set("root", this.createRecord("root", this.defaultRootPassword));
86
+ let changed = false;
87
+ if (!this.users.has("root")) {
88
+ this.users.set(
89
+ "root",
90
+ this.createRecord("root", this.defaultRootPassword),
91
+ );
92
+ changed = true;
93
+ }
71
94
 
72
95
  this.sudoers.add("root");
73
96
 
74
97
  // Auto-create current system user for easier authentication
75
98
  const currentUser = process.env.USER || process.env.USERNAME;
76
99
  if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
77
- // Use same password as root for convenience, or a generic default
78
100
  const userPassword = this.defaultRootPassword;
79
101
  this.users.set(currentUser, this.createRecord(currentUser, userPassword));
80
102
  this.sudoers.add(currentUser);
103
+ changed = true;
81
104
 
82
- // Create home directory for the system user
83
105
  const homePath = `/home/${currentUser}`;
84
106
  if (!this.vfs.exists(homePath)) {
85
107
  this.vfs.mkdir(homePath, 0o755);
@@ -90,7 +112,9 @@ export class VirtualUserManager extends EventEmitter {
90
112
  }
91
113
  }
92
114
 
93
- await this.persist();
115
+ if (changed) {
116
+ await this.persist();
117
+ }
94
118
  this.emit("initialized");
95
119
  }
96
120
 
@@ -104,6 +128,7 @@ export class VirtualUserManager extends EventEmitter {
104
128
  username: string,
105
129
  maxBytes: number,
106
130
  ): Promise<void> {
131
+ perf.mark("setQuotaBytes");
107
132
  this.validateUsername(username);
108
133
  if (!this.users.has(username)) {
109
134
  throw new Error(`quota: user '${username}' does not exist`);
@@ -123,6 +148,7 @@ export class VirtualUserManager extends EventEmitter {
123
148
  * @param username Target username.
124
149
  */
125
150
  public async clearQuota(username: string): Promise<void> {
151
+ perf.mark("clearQuota");
126
152
  this.validateUsername(username);
127
153
  this.quotas.delete(username);
128
154
  await this.persist();
@@ -135,6 +161,7 @@ export class VirtualUserManager extends EventEmitter {
135
161
  * @returns Quota in bytes, or null when unlimited.
136
162
  */
137
163
  public getQuotaBytes(username: string): number | null {
164
+ perf.mark("getQuotaBytes");
138
165
  return this.quotas.get(username) ?? null;
139
166
  }
140
167
 
@@ -145,6 +172,7 @@ export class VirtualUserManager extends EventEmitter {
145
172
  * @returns Current usage in bytes.
146
173
  */
147
174
  public getUsageBytes(username: string): number {
175
+ perf.mark("getUsageBytes");
148
176
  const homePath = `/home/${username}`;
149
177
  if (!this.vfs.exists(homePath)) {
150
178
  return 0;
@@ -167,6 +195,7 @@ export class VirtualUserManager extends EventEmitter {
167
195
  targetPath: string,
168
196
  nextContent: string | Buffer,
169
197
  ): void {
198
+ perf.mark("assertWriteWithinQuota");
170
199
  const quota = this.quotas.get(username);
171
200
  if (quota === undefined) {
172
201
  return;
@@ -209,6 +238,7 @@ export class VirtualUserManager extends EventEmitter {
209
238
  * @returns True when credentials are valid.
210
239
  */
211
240
  public verifyPassword(username: string, password: string): boolean {
241
+ perf.mark("verifyPassword");
212
242
  const record = this.users.get(username);
213
243
  if (!record) {
214
244
  return false;
@@ -224,6 +254,7 @@ export class VirtualUserManager extends EventEmitter {
224
254
  * @param password Initial plaintext password.
225
255
  */
226
256
  public async addUser(username: string, password: string): Promise<void> {
257
+ perf.mark("addUser");
227
258
  this.validateUsername(username);
228
259
  this.validatePassword(password);
229
260
 
@@ -255,6 +286,7 @@ export class VirtualUserManager extends EventEmitter {
255
286
  * @param password New plaintext password.
256
287
  */
257
288
  public async setPassword(username: string, password: string): Promise<void> {
289
+ perf.mark("setPassword");
258
290
  this.validateUsername(username);
259
291
  this.validatePassword(password);
260
292
 
@@ -272,6 +304,7 @@ export class VirtualUserManager extends EventEmitter {
272
304
  * @param username Username to remove.
273
305
  */
274
306
  public async deleteUser(username: string): Promise<void> {
307
+ perf.mark("deleteUser");
275
308
  this.validateUsername(username);
276
309
 
277
310
  if (username === "root") {
@@ -295,6 +328,7 @@ export class VirtualUserManager extends EventEmitter {
295
328
  * @returns True when user can run sudo.
296
329
  */
297
330
  public isSudoer(username: string): boolean {
331
+ perf.mark("isSudoer");
298
332
  return this.sudoers.has(username);
299
333
  }
300
334
 
@@ -304,6 +338,7 @@ export class VirtualUserManager extends EventEmitter {
304
338
  * @param username Username to promote.
305
339
  */
306
340
  public async addSudoer(username: string): Promise<void> {
341
+ perf.mark("addSudoer");
307
342
  this.validateUsername(username);
308
343
  if (!this.users.has(username)) {
309
344
  throw new Error(`sudoers: user '${username}' does not exist`);
@@ -319,6 +354,7 @@ export class VirtualUserManager extends EventEmitter {
319
354
  * @param username Username to demote.
320
355
  */
321
356
  public async removeSudoer(username: string): Promise<void> {
357
+ perf.mark("removeSudoer");
322
358
  this.validateUsername(username);
323
359
  if (username === "root") {
324
360
  throw new Error("sudoers: cannot remove root");
@@ -339,6 +375,7 @@ export class VirtualUserManager extends EventEmitter {
339
375
  username: string,
340
376
  remoteAddress: string,
341
377
  ): VirtualActiveSession {
378
+ perf.mark("registerSession");
342
379
  const session: VirtualActiveSession = {
343
380
  id: randomUUID(),
344
381
  username,
@@ -361,6 +398,7 @@ export class VirtualUserManager extends EventEmitter {
361
398
  * @param sessionId Session identifier; ignored when nullish.
362
399
  */
363
400
  public unregisterSession(sessionId: string | null | undefined): void {
401
+ perf.mark("unregisterSession");
364
402
  if (!sessionId) {
365
403
  return;
366
404
  }
@@ -388,6 +426,7 @@ export class VirtualUserManager extends EventEmitter {
388
426
  username: string,
389
427
  remoteAddress: string,
390
428
  ): void {
429
+ perf.mark("updateSession");
391
430
  if (!sessionId) {
392
431
  return;
393
432
  }
@@ -410,6 +449,7 @@ export class VirtualUserManager extends EventEmitter {
410
449
  * @returns Snapshot of active session descriptors.
411
450
  */
412
451
  public listActiveSessions(): VirtualActiveSession[] {
452
+ perf.mark("listActiveSessions");
413
453
  return Array.from(this.activeSessions.values()).sort((left, right) =>
414
454
  left.startedAt.localeCompare(right.startedAt),
415
455
  );
@@ -488,47 +528,84 @@ export class VirtualUserManager extends EventEmitter {
488
528
  this.vfs.mkdir(this.authDirPath, 0o700);
489
529
  }
490
530
 
491
- const content = Array.from(this.users.values())
531
+ const authContent = Array.from(this.users.values())
492
532
  .sort((left, right) => left.username.localeCompare(right.username))
493
533
  .map((record) =>
494
534
  [record.username, record.salt, record.passwordHash].join(":"),
495
535
  )
496
536
  .join("\n");
497
-
498
- this.vfs.writeFile(
499
- this.usersPath,
500
- content.length > 0 ? `${content}\n` : "",
501
- { mode: 0o600 },
502
- );
503
537
  const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
504
- this.vfs.writeFile(
505
- this.sudoersPath,
506
- sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
507
- { mode: 0o600 },
508
- );
509
538
  const quotasContent = Array.from(this.quotas.entries())
510
539
  .sort(([left], [right]) => left.localeCompare(right))
511
540
  .map(([username, maxBytes]) => `${username}:${maxBytes}`)
512
541
  .join("\n");
513
- this.vfs.writeFile(
514
- this.quotasPath,
515
- quotasContent.length > 0 ? `${quotasContent}\n` : "",
516
- { mode: 0o600 },
517
- );
518
- await this.vfs.flushMirror();
542
+
543
+ let changed = false;
544
+ changed =
545
+ this.writeIfChanged(
546
+ this.usersPath,
547
+ authContent.length > 0 ? `${authContent}\n` : "",
548
+ 0o600,
549
+ ) || changed;
550
+ changed =
551
+ this.writeIfChanged(
552
+ this.sudoersPath,
553
+ sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
554
+ 0o600,
555
+ ) || changed;
556
+ changed =
557
+ this.writeIfChanged(
558
+ this.quotasPath,
559
+ quotasContent.length > 0 ? `${quotasContent}\n` : "",
560
+ 0o600,
561
+ ) || changed;
562
+
563
+ if (changed) {
564
+ await this.vfs.flushMirror();
565
+ }
566
+ }
567
+
568
+ private writeIfChanged(
569
+ targetPath: string,
570
+ content: string,
571
+ mode: number,
572
+ ): boolean {
573
+ if (this.vfs.exists(targetPath)) {
574
+ const existing = this.vfs.readFile(targetPath);
575
+ if (existing === content) {
576
+ this.vfs.chmod(targetPath, mode);
577
+ return false;
578
+ }
579
+ }
580
+
581
+ this.vfs.writeFile(targetPath, content, { mode });
582
+ return true;
519
583
  }
520
584
 
521
585
  private createRecord(username: string, password: string): VirtualUserRecord {
586
+ const cacheKey = `${username}:${password}`;
587
+ const cached = VirtualUserManager.recordCache.get(cacheKey);
588
+ if (cached) {
589
+ return cached;
590
+ }
591
+
522
592
  const salt = randomBytes(16).toString("hex");
523
- return {
593
+ const record = {
524
594
  username,
525
595
  salt,
526
596
  passwordHash: this.hashPassword(password, salt),
527
597
  };
598
+
599
+ VirtualUserManager.recordCache.set(cacheKey, record);
600
+ return record;
528
601
  }
529
602
 
530
603
  private hashPassword(password: string, salt: string): string {
531
- return scryptSync(password, salt, 64).toString("hex");
604
+ if (VirtualUserManager.fastPasswordHash) {
605
+ return createHash("sha256").update(`${salt}:${password}`).digest("hex");
606
+ }
607
+
608
+ return scryptSync(password, salt, 32).toString("hex");
532
609
  }
533
610
 
534
611
  private validateUsername(username: string): void {
@@ -0,0 +1,72 @@
1
+ export type PerfLogger = {
2
+ enabled: boolean;
3
+ mark: (label: string) => void;
4
+ done: (label?: string) => void;
5
+ };
6
+
7
+ function isTruthyEnv(value: string | undefined): boolean {
8
+ return value === "1" || value === "true";
9
+ }
10
+
11
+ function nowMs(): number {
12
+ if (
13
+ typeof performance !== "undefined" &&
14
+ typeof performance.now === "function"
15
+ ) {
16
+ return performance.now();
17
+ }
18
+
19
+ return Date.now();
20
+ }
21
+
22
+ export function isPerfLoggingEnabled(): boolean {
23
+ return (
24
+ isTruthyEnv(process.env.DEV_MODE) || isTruthyEnv(process.env.RENDER_PERF)
25
+ );
26
+ }
27
+
28
+ export function createPerfLogger(scope: string): PerfLogger {
29
+ const enabled = isPerfLoggingEnabled();
30
+ if (!enabled) {
31
+ return {
32
+ enabled,
33
+ mark: () => undefined,
34
+ done: () => undefined,
35
+ };
36
+ }
37
+
38
+ const startedAt = nowMs();
39
+
40
+ const mark = (label: string): void => {
41
+ const elapsedMs = nowMs() - startedAt;
42
+ console.log(`[perf][${scope}] ${label}: ${elapsedMs.toFixed(1)}ms`);
43
+ };
44
+
45
+ const done = (label = "done"): void => {
46
+ mark(label);
47
+ };
48
+
49
+ return {
50
+ enabled,
51
+ mark,
52
+ done,
53
+ };
54
+ }
55
+
56
+ export async function withPerf<T>(
57
+ scope: string,
58
+ label: string,
59
+ work: () => Promise<T>,
60
+ ): Promise<T> {
61
+ const perf = createPerfLogger(scope);
62
+ if (!perf.enabled) {
63
+ return work();
64
+ }
65
+
66
+ perf.mark(`${label}:start`);
67
+ try {
68
+ return await work();
69
+ } finally {
70
+ perf.done(`${label}:done`);
71
+ }
72
+ }