typescript-virtual-container 1.1.0 → 1.1.1-b

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 (50) hide show
  1. package/.vscode/settings.json +18 -0
  2. package/README.md +45 -5
  3. package/modules/shellInteractive.ts +45 -0
  4. package/modules/shellRuntime.ts +76 -0
  5. package/package.json +1 -1
  6. package/src/{SSHMimic/client.ts → SSHClient/index.ts} +2 -2
  7. package/src/SSHMimic/exec.ts +1 -1
  8. package/src/SSHMimic/executor.ts +8 -8
  9. package/src/VirtualFileSystem/index.ts +26 -0
  10. package/src/VirtualShell/index.ts +17 -1
  11. package/src/VirtualShell/shell.ts +19 -107
  12. package/src/VirtualShell/shellParser.ts +32 -7
  13. package/src/VirtualUserManager/index.ts +149 -0
  14. package/src/{VirtualShell/commands → commands}/adduser.ts +1 -1
  15. package/src/{VirtualShell/commands → commands}/cat.ts +1 -1
  16. package/src/{VirtualShell/commands → commands}/cd.ts +1 -1
  17. package/src/{VirtualShell/commands → commands}/clear.ts +1 -1
  18. package/src/{VirtualShell/commands → commands}/curl.ts +2 -2
  19. package/src/{VirtualShell/commands → commands}/deluser.ts +1 -1
  20. package/src/{VirtualShell/commands → commands}/echo.ts +1 -1
  21. package/src/{VirtualShell/commands → commands}/env.ts +1 -1
  22. package/src/{VirtualShell/commands → commands}/exit.ts +1 -1
  23. package/src/{VirtualShell/commands → commands}/export.ts +1 -1
  24. package/src/{VirtualShell/commands → commands}/grep.ts +1 -1
  25. package/src/{VirtualShell/commands → commands}/help.ts +1 -1
  26. package/src/{VirtualShell/commands → commands}/helpers.ts +1 -1
  27. package/src/{VirtualShell/commands → commands}/hostname.ts +1 -1
  28. package/src/{VirtualShell/commands → commands}/htop.ts +1 -1
  29. package/src/{VirtualShell/commands → commands}/index.ts +7 -4
  30. package/src/{VirtualShell/commands → commands}/ls.ts +1 -1
  31. package/src/{VirtualShell/commands → commands}/mkdir.ts +1 -1
  32. package/src/{VirtualShell/commands → commands}/nano.ts +1 -1
  33. package/src/{VirtualShell/commands → commands}/neofetch.ts +2 -2
  34. package/src/{VirtualShell/commands → commands}/pwd.ts +1 -1
  35. package/src/{VirtualShell/commands → commands}/rm.ts +1 -1
  36. package/src/{VirtualShell/commands → commands}/set.ts +1 -1
  37. package/src/{VirtualShell/commands → commands}/sh.ts +1 -1
  38. package/src/{VirtualShell/commands → commands}/su.ts +1 -1
  39. package/src/{VirtualShell/commands → commands}/sudo.ts +1 -1
  40. package/src/{VirtualShell/commands → commands}/touch.ts +2 -2
  41. package/src/{VirtualShell/commands → commands}/tree.ts +1 -1
  42. package/src/{VirtualShell/commands → commands}/unset.ts +1 -1
  43. package/src/{VirtualShell/commands → commands}/wget.ts +2 -2
  44. package/src/{VirtualShell/commands → commands}/who.ts +2 -2
  45. package/src/{VirtualShell/commands → commands}/whoami.ts +1 -1
  46. package/src/index.ts +2 -2
  47. package/tests/command-helpers.test.ts +1 -1
  48. package/tests/helpers.test.ts +1 -1
  49. package/tests/users.test.ts +60 -0
  50. /package/src/{VirtualShell/commands → commands}/command-helpers.ts +0 -0
@@ -0,0 +1,18 @@
1
+ {
2
+ "files.exclude": {
3
+ "**/.git": true,
4
+ "**/.hg": true,
5
+ "**/.svn": true,
6
+ "**/.DS_Store": true,
7
+ "**/Thumbs.db": true,
8
+ "**/node_modules": true,
9
+
10
+ ".ssh-mimic": true,
11
+ ".vfs": true,
12
+
13
+ "LICENSE": true,
14
+ // "*.md": true,
15
+ "biome.json": true,
16
+ "tsconfig.tsbuildinfo": true
17
+ }
18
+ }
package/README.md CHANGED
@@ -83,7 +83,7 @@ bun add typescript-virtual-container
83
83
  ### From source (development)
84
84
 
85
85
  ```bash
86
- git clone <repo-url>
86
+ git clone https://github.com/itsrealfortune/typescript-virtual-container/
87
87
  cd virtual-env-js
88
88
  bun install
89
89
  bun format # Format code per Biome
@@ -745,6 +745,46 @@ Revokes sudo privileges. Cannot remove root.
745
745
  await users.removeSudoer("charlie");
746
746
  ```
747
747
 
748
+ ##### `async setQuotaBytes(username: string, maxBytes: number): Promise<void>`
749
+
750
+ Sets an optional per-user quota (bytes) for writes under `/home/<username>`.
751
+
752
+ ```typescript
753
+ await users.setQuotaBytes("alice", 5 * 1024 * 1024); // 5 MB
754
+ ```
755
+
756
+ ##### `async clearQuota(username: string): Promise<void>`
757
+
758
+ Removes quota limit for a user.
759
+
760
+ ```typescript
761
+ await users.clearQuota("alice");
762
+ ```
763
+
764
+ ##### `getQuotaBytes(username: string): number | null`
765
+
766
+ Returns configured quota in bytes, or `null` if unlimited.
767
+
768
+ ```typescript
769
+ console.log(users.getQuotaBytes("alice"));
770
+ ```
771
+
772
+ ##### `getUsageBytes(username: string): number`
773
+
774
+ Returns current stored usage in bytes under `/home/<username>`.
775
+
776
+ ```typescript
777
+ console.log(users.getUsageBytes("alice"));
778
+ ```
779
+
780
+ ##### `assertWriteWithinQuota(username: string, targetPath: string, nextContent: string | Buffer): void`
781
+
782
+ Validates a write operation against quota rules; throws when projected usage exceeds quota.
783
+
784
+ ```typescript
785
+ users.assertWriteWithinQuota("alice", "/home/alice/data.txt", "payload");
786
+ ```
787
+
748
788
  ##### `registerSession(username: string, remoteAddress: string): VirtualActiveSession`
749
789
 
750
790
  Creates active session (called on SSH auth). Returns session descriptor with UUID, tty, start time.
@@ -1358,9 +1398,9 @@ MIT License. See LICENSE file for details.
1358
1398
 
1359
1399
  ## Roadmap
1360
1400
 
1361
- - [ ] Custom command plugin API
1362
- - [ ] Optional per-user quotas for virtual filesystem usage
1363
- - [ ] Improved shell compatibility for complex piping and redirection
1401
+ - [x] Custom command plugin API
1402
+ - [x] Optional per-user quotas for virtual filesystem usage
1403
+ - [x] Improved shell compatibility for complex piping and redirection
1364
1404
  - [ ] Snapshot diff tooling for test assertions
1365
- - [ ] Structured event hooks (session open/close, file write, sudo challenge)
1405
+ - [x] Structured event hooks (session open/close, file write, sudo challenge)
1366
1406
  - [ ] WebSocket-based remote shell client (experimental)
@@ -0,0 +1,45 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import type { ShellStream } from "../src/types/streams";
3
+ import { shellQuote, type TerminalSize, withTerminalSize } from "./shellRuntime";
4
+
5
+ function spawnScriptProcess(
6
+ command: string,
7
+ terminalSize: TerminalSize,
8
+ stream: ShellStream,
9
+ ): ChildProcessWithoutNullStreams {
10
+ const formatted = withTerminalSize(command, terminalSize);
11
+ const proc = spawn("script", ["-qfec", formatted, "/dev/null"], {
12
+ stdio: ["pipe", "pipe", "pipe"],
13
+ env: {
14
+ ...process.env,
15
+ // biome-ignore lint/style/useNamingConvention: env variable should be uppercase
16
+ TERM: process.env.TERM ?? "xterm-256color",
17
+ },
18
+ });
19
+
20
+ proc.stdout.on("data", (data: Buffer) => {
21
+ stream.write(data.toString("utf8"));
22
+ });
23
+
24
+ proc.stderr.on("data", (data: Buffer) => {
25
+ stream.write(data.toString("utf8"));
26
+ });
27
+
28
+ return proc;
29
+ }
30
+
31
+ export function spawnNanoEditorProcess(
32
+ tempPath: string,
33
+ terminalSize: TerminalSize,
34
+ stream: ShellStream,
35
+ ): ChildProcessWithoutNullStreams {
36
+ return spawnScriptProcess(`nano -- ${shellQuote(tempPath)}`, terminalSize, stream);
37
+ }
38
+
39
+ export function spawnHtopProcess(
40
+ pidList: string,
41
+ terminalSize: TerminalSize,
42
+ stream: ShellStream,
43
+ ): ChildProcessWithoutNullStreams {
44
+ return spawnScriptProcess(`htop -p ${shellQuote(pidList)}`, terminalSize, stream);
45
+ }
@@ -0,0 +1,76 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ export interface TerminalSize {
5
+ cols: number;
6
+ rows: number;
7
+ }
8
+
9
+ export function shellQuote(value: string): string {
10
+ return `'${value.replace(/'/g, `'\\''`)}'`;
11
+ }
12
+
13
+ export function toTtyLines(text: string): string {
14
+ return text
15
+ .replace(/\r\n/g, "\n")
16
+ .replace(/\r/g, "\n")
17
+ .replace(/\n/g, "\r\n");
18
+ }
19
+
20
+ export function withTerminalSize(
21
+ command: string,
22
+ terminalSize: TerminalSize,
23
+ ): string {
24
+ const cols =
25
+ Number.isFinite(terminalSize.cols) && terminalSize.cols > 0
26
+ ? Math.floor(terminalSize.cols)
27
+ : 80;
28
+ const rows =
29
+ Number.isFinite(terminalSize.rows) && terminalSize.rows > 0
30
+ ? Math.floor(terminalSize.rows)
31
+ : 24;
32
+ return `stty cols ${cols} rows ${rows} 2>/dev/null; ${command}`;
33
+ }
34
+
35
+ export function resolvePath(base: string, inputPath: string): string {
36
+ if (!inputPath || inputPath.trim() === "" || inputPath === ".") {
37
+ return base;
38
+ }
39
+ return inputPath.startsWith("/")
40
+ ? path.posix.normalize(inputPath)
41
+ : path.posix.normalize(path.posix.join(base, inputPath));
42
+ }
43
+
44
+ export async function collectChildPids(parentPid: number): Promise<number[]> {
45
+ try {
46
+ const childrenRaw = await readFile(
47
+ `/proc/${parentPid}/task/${parentPid}/children`,
48
+ "utf8",
49
+ );
50
+ const directChildren = childrenRaw
51
+ .trim()
52
+ .split(/\s+/)
53
+ .filter(Boolean)
54
+ .map((value) => Number.parseInt(value, 10))
55
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
56
+
57
+ const nested = await Promise.all(
58
+ directChildren.map((pid) => collectChildPids(pid)),
59
+ );
60
+ return [...directChildren, ...nested.flat()];
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ export async function getVisibleHtopPidList(
67
+ rootPid = process.pid,
68
+ ): Promise<string | null> {
69
+ const descendants = await collectChildPids(rootPid);
70
+ const unique = Array.from(new Set(descendants)).sort((a, b) => a - b);
71
+ if (unique.length === 0) {
72
+ return null;
73
+ }
74
+
75
+ return unique.join(",");
76
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "In-memory SSH server with virtual filesystem and typed programmatic API",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
- "version": "1.1.0",
6
+ "version": "1.1.1b",
7
7
  "license": "MIT",
8
8
  "keywords": [
9
9
  "ssh",
@@ -1,6 +1,6 @@
1
+ import { runCommand } from "../commands";
1
2
  import type { CommandResult } from "../types/commands";
2
3
  import type { VirtualShell } from "../VirtualShell";
3
- import { runCommand } from "../VirtualShell/commands";
4
4
 
5
5
  /**
6
6
  * Programmatic client for executing shell commands against a virtual shell.
@@ -154,7 +154,7 @@ export class SshClient {
154
154
  }
155
155
 
156
156
  try {
157
- vfs.writeFile(path, content);
157
+ this.shell.writeFileAsUser(this.username, path, content);
158
158
  return { stdout: `File '${path}' written`, exitCode: 0 };
159
159
  } catch (error) {
160
160
  return {
@@ -1,6 +1,6 @@
1
+ import { runCommand } from "../commands";
1
2
  import type { ExecStream } from "../types/streams";
2
3
  import type { VirtualShell } from "../VirtualShell";
3
- import { runCommand } from "../VirtualShell/commands";
4
4
 
5
5
  function toTtyLines(text: string): string {
6
6
  return text
@@ -1,8 +1,8 @@
1
+ import { runCommand as runSingleCommand } from "../commands";
2
+ import { resolvePath } from "../commands/helpers";
1
3
  import type { CommandMode, CommandResult } from "../types/commands";
2
4
  import type { Pipeline, PipelineCommand } from "../types/pipeline";
3
5
  import type { VirtualShell } from "../VirtualShell";
4
- import { runCommand as runSingleCommand } from "../VirtualShell/commands";
5
- import { resolvePath } from "../VirtualShell/commands/helpers";
6
6
 
7
7
  /**
8
8
  * Execute a parsed pipeline, chaining commands and handling redirections.
@@ -90,12 +90,12 @@ async function executeSingleCommandWithRedirections(
90
90
  if (cmd.appendOutput) {
91
91
  try {
92
92
  const existing = shell.vfs.readFile(outputPath);
93
- shell.vfs.writeFile(outputPath, existing + output);
93
+ shell.writeFileAsUser(authUser, outputPath, existing + output);
94
94
  } catch {
95
- shell.vfs.writeFile(outputPath, output);
95
+ shell.writeFileAsUser(authUser, outputPath, output);
96
96
  }
97
97
  } else {
98
- shell.vfs.writeFile(outputPath, output);
98
+ shell.writeFileAsUser(authUser, outputPath, output);
99
99
  }
100
100
  return { ...result, stdout: "" };
101
101
  } catch {
@@ -165,12 +165,12 @@ async function executePipelineChain(
165
165
  if (cmd.appendOutput) {
166
166
  try {
167
167
  const existing = shell.vfs.readFile(outputPath);
168
- shell.vfs.writeFile(outputPath, existing + output);
168
+ shell.writeFileAsUser(authUser, outputPath, existing + output);
169
169
  } catch {
170
- shell.vfs.writeFile(outputPath, output);
170
+ shell.writeFileAsUser(authUser, outputPath, output);
171
171
  }
172
172
  } else {
173
- shell.vfs.writeFile(outputPath, output);
173
+ shell.writeFileAsUser(authUser, outputPath, output);
174
174
  }
175
175
  currentOutput = "";
176
176
  } catch {
@@ -24,6 +24,18 @@ class VirtualFileSystem {
24
24
  private readonly archivePath: string;
25
25
  private dirty = false;
26
26
 
27
+ private computeNodeUsageBytes(node: InternalNode): number {
28
+ if (node.type === "file") {
29
+ return node.content.length;
30
+ }
31
+
32
+ let total = 0;
33
+ for (const child of node.children.values()) {
34
+ total += this.computeNodeUsageBytes(child);
35
+ }
36
+ return total;
37
+ }
38
+
27
39
  /**
28
40
  * Creates a virtual filesystem instance.
29
41
  *
@@ -287,6 +299,20 @@ class VirtualFileSystem {
287
299
  return renderTree(node, rootLabel);
288
300
  }
289
301
 
302
+ /**
303
+ * Computes total stored file bytes under a path.
304
+ *
305
+ * File usage is based on in-memory stored bytes, including compressed
306
+ * payload size when files are marked as compressed.
307
+ *
308
+ * @param targetPath File or directory path to measure, defaults to root.
309
+ * @returns Total byte usage for file content under target path.
310
+ */
311
+ public getUsageBytes(targetPath: string = "/"): number {
312
+ const node = getNode(this.root, targetPath);
313
+ return this.computeNodeUsageBytes(node);
314
+ }
315
+
290
316
  /**
291
317
  * Compresses file content with gzip and flags node as compressed.
292
318
  *
@@ -1,9 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { createCustomCommand, registerCommand, runCommand } from "../commands";
2
3
  import type { CommandContext, CommandResult } from "../types/commands";
3
4
  import type { ShellStream } from "../types/streams";
4
5
  import VirtualFileSystem from "../VirtualFileSystem";
5
6
  import { VirtualUserManager } from "../VirtualUserManager";
6
- import { createCustomCommand, registerCommand, runCommand } from "./commands";
7
7
  import { startShell } from "./shell";
8
8
 
9
9
  export interface ShellProperties {
@@ -170,6 +170,22 @@ class VirtualShell {
170
170
  public getHostname(): string {
171
171
  return this?.hostname;
172
172
  }
173
+
174
+ /**
175
+ * Writes a file on behalf of a user with quota enforcement.
176
+ *
177
+ * @param authUser User performing the write.
178
+ * @param targetPath Destination path.
179
+ * @param content File content.
180
+ */
181
+ public writeFileAsUser(
182
+ authUser: string,
183
+ targetPath: string,
184
+ content: string | Buffer,
185
+ ): void {
186
+ this.users.assertWriteWithinQuota(authUser, targetPath, content);
187
+ this.vfs.writeFile(targetPath, content);
188
+ }
173
189
  }
174
190
 
175
191
  export { VirtualShell };
@@ -1,12 +1,22 @@
1
- import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import type { ShellProperties, VirtualShell } from ".";
5
+ import {
6
+ spawnHtopProcess,
7
+ spawnNanoEditorProcess,
8
+ } from "../../modules/shellInteractive";
9
+ import {
10
+ getVisibleHtopPidList,
11
+ resolvePath,
12
+ type TerminalSize,
13
+ toTtyLines,
14
+ } from "../../modules/shellRuntime";
15
+ import { getCommandNames, runCommand } from "../commands";
5
16
  import { formatLoginDate } from "../SSHMimic/loginFormat";
6
17
  import { buildPrompt } from "../SSHMimic/prompt";
7
18
  import type { ShellStream } from "../types/streams";
8
19
  import type VirtualFileSystem from "../VirtualFileSystem";
9
- import { getCommandNames, runCommand } from "./commands";
10
20
 
11
21
  interface NanoSession {
12
22
  kind: "nano" | "htop";
@@ -24,22 +34,6 @@ interface PendingSudo {
24
34
  buffer: string;
25
35
  }
26
36
 
27
- function shellQuote(value: string): string {
28
- return `'${value.replace(/'/g, `'\\''`)}'`;
29
- }
30
-
31
- interface TerminalSize {
32
- cols: number;
33
- rows: number;
34
- }
35
-
36
- function toTtyLines(text: string): string {
37
- return text
38
- .replace(/\r\n/g, "\n")
39
- .replace(/\r/g, "\n")
40
- .replace(/\n/g, "\r\n");
41
- }
42
-
43
37
  export function startShell(
44
38
  properties: ShellProperties,
45
39
  stream: ShellStream,
@@ -68,60 +62,6 @@ export function startShell(
68
62
  `[${sessionId}] Shell started for user '${authUser}' at ${remoteAddress}`,
69
63
  );
70
64
 
71
- async function collectChildPids(parentPid: number): Promise<number[]> {
72
- try {
73
- const childrenRaw = await readFile(
74
- `/proc/${parentPid}/task/${parentPid}/children`,
75
- "utf8",
76
- );
77
- const directChildren = childrenRaw
78
- .trim()
79
- .split(/\s+/)
80
- .filter(Boolean)
81
- .map((value) => Number.parseInt(value, 10))
82
- .filter((pid) => Number.isInteger(pid) && pid > 0);
83
-
84
- const nested = await Promise.all(
85
- directChildren.map((pid) => collectChildPids(pid)),
86
- );
87
- return [...directChildren, ...nested.flat()];
88
- } catch {
89
- return [];
90
- }
91
- }
92
-
93
- async function getVisibleHtopPidList(): Promise<string | null> {
94
- const rootPid = process.pid;
95
- const descendants = await collectChildPids(rootPid);
96
- const unique = Array.from(new Set(descendants)).sort((a, b) => a - b);
97
- if (unique.length === 0) {
98
- return null;
99
- }
100
-
101
- return unique.join(",");
102
- }
103
-
104
- function withTerminalSize(command: string): string {
105
- const cols =
106
- Number.isFinite(terminalSize.cols) && terminalSize.cols > 0
107
- ? Math.floor(terminalSize.cols)
108
- : 80;
109
- const rows =
110
- Number.isFinite(terminalSize.rows) && terminalSize.rows > 0
111
- ? Math.floor(terminalSize.rows)
112
- : 24;
113
- return `stty cols ${cols} rows ${rows} 2>/dev/null; ${command}`;
114
- }
115
-
116
- function resolvePath(base: string, inputPath: string): string {
117
- if (!inputPath || inputPath.trim() === "" || inputPath === ".") {
118
- return base;
119
- }
120
- return inputPath.startsWith("/")
121
- ? path.posix.normalize(inputPath)
122
- : path.posix.normalize(path.posix.join(base, inputPath));
123
- }
124
-
125
65
  function renderLine(): void {
126
66
  const prompt = buildCurrentPrompt();
127
67
  stream.write(`\r${prompt}${lineBuffer}\u001b[K`);
@@ -236,7 +176,11 @@ export function startShell(
236
176
  if (activeSession.kind === "nano") {
237
177
  try {
238
178
  const updatedContent = await readFile(activeSession.tempPath, "utf8");
239
- shell.vfs.writeFile(activeSession.targetPath, updatedContent);
179
+ shell.writeFileAsUser(
180
+ authUser,
181
+ activeSession.targetPath,
182
+ updatedContent,
183
+ );
240
184
  await shell.vfs.flushMirror();
241
185
  } catch {
242
186
  // If temp file does not exist, nano exited without writing.
@@ -261,23 +205,7 @@ export function startShell(
261
205
  await writeFile(tempPath, initialContent, "utf8");
262
206
  }
263
207
 
264
- const command = withTerminalSize(`nano -- ${shellQuote(tempPath)}`);
265
- const editor = spawn("script", ["-qfec", command, "/dev/null"], {
266
- stdio: ["pipe", "pipe", "pipe"],
267
- env: {
268
- ...process.env,
269
- // biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
270
- TERM: process.env.TERM ?? "xterm-256color",
271
- },
272
- });
273
-
274
- editor.stdout.on("data", (data: Buffer) => {
275
- stream.write(data.toString("utf8"));
276
- });
277
-
278
- editor.stderr.on("data", (data: Buffer) => {
279
- stream.write(data.toString("utf8"));
280
- });
208
+ const editor = spawnNanoEditorProcess(tempPath, terminalSize, stream);
281
209
 
282
210
  editor.on("error", (error: Error) => {
283
211
  stream.write(`nano: ${error.message}\r\n`);
@@ -303,23 +231,7 @@ export function startShell(
303
231
  return;
304
232
  }
305
233
 
306
- const command = withTerminalSize(`htop -p ${shellQuote(pidList)}`);
307
- const monitor = spawn("script", ["-qfec", command, "/dev/null"], {
308
- stdio: ["pipe", "pipe", "pipe"],
309
- env: {
310
- ...process.env,
311
- // biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
312
- TERM: process.env.TERM ?? "xterm-256color",
313
- },
314
- });
315
-
316
- monitor.stdout.on("data", (data: Buffer) => {
317
- stream.write(data.toString("utf8"));
318
- });
319
-
320
- monitor.stderr.on("data", (data: Buffer) => {
321
- stream.write(data.toString("utf8"));
322
- });
234
+ const monitor = spawnHtopProcess(pidList, terminalSize, stream);
323
235
 
324
236
  monitor.on("error", (error: Error) => {
325
237
  stream.write(`htop: ${error.message}\r\n`);
@@ -9,7 +9,16 @@ export function parseShellPipeline(rawInput: string): Pipeline {
9
9
  }
10
10
 
11
11
  const commands: PipelineCommand[] = [];
12
- const pipeTokens = tokenizePipeline(trimmed);
12
+ const tokenized = tokenizePipeline(trimmed);
13
+ if (tokenized.error) {
14
+ return {
15
+ commands: [],
16
+ isValid: false,
17
+ error: tokenized.error,
18
+ };
19
+ }
20
+
21
+ const pipeTokens = tokenized.tokens;
13
22
 
14
23
  for (const token of pipeTokens) {
15
24
  const cmd = parseCommandWithRedirections(token);
@@ -29,7 +38,7 @@ export function parseShellPipeline(rawInput: string): Pipeline {
29
38
  }
30
39
 
31
40
  /** Tokenize input by pipes, respecting quoted strings */
32
- function tokenizePipeline(input: string): string[] {
41
+ function tokenizePipeline(input: string): { tokens: string[]; error?: string } {
33
42
  const tokens: string[] = [];
34
43
  let current = "";
35
44
  let inQuotes = false;
@@ -49,9 +58,13 @@ function tokenizePipeline(input: string): string[] {
49
58
  current += ch;
50
59
  i++;
51
60
  } else if (ch === "|" && !inQuotes) {
52
- if (current.trim()) {
53
- tokens.push(current.trim());
61
+ if (!current.trim()) {
62
+ return {
63
+ tokens: [],
64
+ error: "Syntax error near unexpected token '|'",
65
+ };
54
66
  }
67
+ tokens.push(current.trim());
55
68
  current = "";
56
69
  i++;
57
70
  } else {
@@ -60,11 +73,23 @@ function tokenizePipeline(input: string): string[] {
60
73
  }
61
74
  }
62
75
 
63
- if (current.trim()) {
64
- tokens.push(current.trim());
76
+ if (inQuotes) {
77
+ return {
78
+ tokens: [],
79
+ error: "Syntax error: unterminated quote",
80
+ };
65
81
  }
66
82
 
67
- return tokens;
83
+ if (!current.trim()) {
84
+ return {
85
+ tokens: [],
86
+ error: "Syntax error near unexpected token '|'",
87
+ };
88
+ }
89
+
90
+ tokens.push(current.trim());
91
+
92
+ return { tokens };
68
93
  }
69
94
 
70
95
  interface ParseResult {
@@ -1,4 +1,5 @@
1
1
  import { randomBytes, randomUUID, scryptSync } from "node:crypto";
2
+ import * as path from "node:path";
2
3
  import type VirtualFileSystem from "../VirtualFileSystem";
3
4
 
4
5
  /** Persisted virtual user credential record. */
@@ -33,9 +34,11 @@ export interface VirtualActiveSession {
33
34
  export class VirtualUserManager {
34
35
  private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
35
36
  private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
37
+ private readonly quotasPath = "/virtual-env-js/.auth/quotas";
36
38
  private readonly authDirPath = "/virtual-env-js/.auth";
37
39
  private readonly users = new Map<string, VirtualUserRecord>();
38
40
  private readonly sudoers = new Set<string>();
41
+ private readonly quotas = new Map<string, number>();
39
42
  private readonly activeSessions = new Map<string, VirtualActiveSession>();
40
43
  private nextTty = 0;
41
44
 
@@ -58,6 +61,7 @@ export class VirtualUserManager {
58
61
  public async initialize(): Promise<void> {
59
62
  this.loadFromVfs();
60
63
  this.loadSudoersFromVfs();
64
+ this.loadQuotasFromVfs();
61
65
 
62
66
  this.users.set("root", this.createRecord("root", this.defaultRootPassword));
63
67
 
@@ -66,6 +70,113 @@ export class VirtualUserManager {
66
70
  await this.persist();
67
71
  }
68
72
 
73
+ /**
74
+ * Sets max allowed bytes under /home/<username>.
75
+ *
76
+ * @param username Target username.
77
+ * @param maxBytes Quota ceiling in bytes.
78
+ */
79
+ public async setQuotaBytes(
80
+ username: string,
81
+ maxBytes: number,
82
+ ): Promise<void> {
83
+ this.validateUsername(username);
84
+ if (!this.users.has(username)) {
85
+ throw new Error(`quota: user '${username}' does not exist`);
86
+ }
87
+
88
+ if (!Number.isFinite(maxBytes) || maxBytes < 0) {
89
+ throw new Error("quota: maxBytes must be a non-negative number");
90
+ }
91
+
92
+ this.quotas.set(username, Math.floor(maxBytes));
93
+ await this.persist();
94
+ }
95
+
96
+ /**
97
+ * Removes quota for a user.
98
+ *
99
+ * @param username Target username.
100
+ */
101
+ public async clearQuota(username: string): Promise<void> {
102
+ this.validateUsername(username);
103
+ this.quotas.delete(username);
104
+ await this.persist();
105
+ }
106
+
107
+ /**
108
+ * Gets configured quota in bytes for a user.
109
+ *
110
+ * @param username Target username.
111
+ * @returns Quota in bytes, or null when unlimited.
112
+ */
113
+ public getQuotaBytes(username: string): number | null {
114
+ return this.quotas.get(username) ?? null;
115
+ }
116
+
117
+ /**
118
+ * Computes current usage under /home/<username>.
119
+ *
120
+ * @param username Target username.
121
+ * @returns Current usage in bytes.
122
+ */
123
+ public getUsageBytes(username: string): number {
124
+ const homePath = `/home/${username}`;
125
+ if (!this.vfs.exists(homePath)) {
126
+ return 0;
127
+ }
128
+
129
+ return this.vfs.getUsageBytes(homePath);
130
+ }
131
+
132
+ /**
133
+ * Validates that writing file content would not exceed user quota.
134
+ *
135
+ * Quotas are enforced only for writes inside /home/<username>.
136
+ *
137
+ * @param username Authenticated user.
138
+ * @param targetPath Target file path.
139
+ * @param nextContent New file content.
140
+ */
141
+ public assertWriteWithinQuota(
142
+ username: string,
143
+ targetPath: string,
144
+ nextContent: string | Buffer,
145
+ ): void {
146
+ const quota = this.quotas.get(username);
147
+ if (quota === undefined) {
148
+ return;
149
+ }
150
+
151
+ const normalizedPath = normalizeVfsPath(targetPath);
152
+ const homePath = normalizeVfsPath(`/home/${username}`);
153
+ const inUserHome =
154
+ normalizedPath === homePath || normalizedPath.startsWith(`${homePath}/`);
155
+ if (!inUserHome) {
156
+ return;
157
+ }
158
+
159
+ const currentUsage = this.getUsageBytes(username);
160
+ let existingSize = 0;
161
+ if (this.vfs.exists(normalizedPath)) {
162
+ const existing = this.vfs.stat(normalizedPath);
163
+ if (existing.type === "file") {
164
+ existingSize = existing.size;
165
+ }
166
+ }
167
+
168
+ const incomingSize = Buffer.isBuffer(nextContent)
169
+ ? nextContent.length
170
+ : Buffer.byteLength(nextContent, "utf8");
171
+ const projectedUsage = currentUsage - existingSize + incomingSize;
172
+
173
+ if (projectedUsage > quota) {
174
+ throw new Error(
175
+ `quota exceeded for '${username}': ${projectedUsage}/${quota} bytes`,
176
+ );
177
+ }
178
+ }
179
+
69
180
  /**
70
181
  * Verifies plaintext password against stored record.
71
182
  *
@@ -291,6 +402,30 @@ export class VirtualUserManager {
291
402
  }
292
403
  }
293
404
 
405
+ private loadQuotasFromVfs(): void {
406
+ this.quotas.clear();
407
+
408
+ if (!this.vfs.exists(this.quotasPath)) {
409
+ return;
410
+ }
411
+
412
+ const raw = this.vfs.readFile(this.quotasPath);
413
+ for (const line of raw.split("\n")) {
414
+ const trimmed = line.trim();
415
+ if (trimmed.length === 0) {
416
+ continue;
417
+ }
418
+
419
+ const [username, value] = trimmed.split(":");
420
+ const bytes = Number.parseInt(value ?? "", 10);
421
+ if (!username || !Number.isFinite(bytes) || bytes < 0) {
422
+ continue;
423
+ }
424
+
425
+ this.quotas.set(username, bytes);
426
+ }
427
+ }
428
+
294
429
  private async persist(): Promise<void> {
295
430
  if (!this.vfs.exists(this.authDirPath)) {
296
431
  this.vfs.mkdir(this.authDirPath, 0o700);
@@ -314,6 +449,15 @@ export class VirtualUserManager {
314
449
  sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
315
450
  { mode: 0o600 },
316
451
  );
452
+ const quotasContent = Array.from(this.quotas.entries())
453
+ .sort(([left], [right]) => left.localeCompare(right))
454
+ .map(([username, maxBytes]) => `${username}:${maxBytes}`)
455
+ .join("\n");
456
+ this.vfs.writeFile(
457
+ this.quotasPath,
458
+ quotasContent.length > 0 ? `${quotasContent}\n` : "",
459
+ { mode: 0o600 },
460
+ );
317
461
  await this.vfs.flushMirror();
318
462
  }
319
463
 
@@ -346,3 +490,8 @@ export class VirtualUserManager {
346
490
  }
347
491
  }
348
492
  }
493
+
494
+ function normalizeVfsPath(targetPath: string): string {
495
+ const normalized = path.posix.normalize(targetPath);
496
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
497
+ }
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const adduserCommand: ShellModule = {
4
4
  name: "adduser",
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getArg } from "./command-helpers";
3
3
  import { assertPathAccess, resolveReadablePath } from "./helpers";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { assertPathAccess, resolvePath } from "./helpers";
3
3
 
4
4
  export const cdCommand: ShellModule = {
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const clearCommand: ShellModule = {
4
4
  name: "clear",
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { parseArgs } from "./command-helpers";
3
3
  import {
4
4
  assertPathAccess,
@@ -39,7 +39,7 @@ export const curlCommand: ShellModule = {
39
39
  if (outputPath) {
40
40
  const target = resolvePath(cwd, outputPath);
41
41
  assertPathAccess(authUser, target, "curl");
42
- shell.vfs.writeFile(target, result.stdout);
42
+ shell.writeFileAsUser(authUser, target, result.stdout);
43
43
  return {
44
44
  stderr: result.stderr
45
45
  ? normalizeTerminalOutput(result.stderr)
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const deluserCommand: ShellModule = {
4
4
  name: "deluser",
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { parseArgs } from "./command-helpers";
3
3
  import { getAllEnvVars } from "./set";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getAllEnvVars } from "./set";
3
3
 
4
4
  export const envCommand: ShellModule = {
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const exitCommand: ShellModule = {
4
4
  name: "exit",
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getArg } from "./command-helpers";
3
3
  import { getEnvVar, setEnvVar } from "./set";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { parseArgs } from "./command-helpers";
3
3
  import { assertPathAccess, resolvePath } from "./helpers";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export function createHelpCommand(getNames: () => string[]): ShellModule {
4
4
  return {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as path from "node:path";
3
- import type VirtualFileSystem from "../../VirtualFileSystem";
3
+ import type VirtualFileSystem from "../VirtualFileSystem";
4
4
 
5
5
  const PROTECTED_PREFIXES = ["/virtual-env-js/.auth"] as const;
6
6
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const hostnameCommand: ShellModule = {
4
4
  name: "hostname",
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const htopCommand: ShellModule = {
4
4
  name: "htop",
@@ -1,10 +1,10 @@
1
- import type { VirtualShell } from "..";
1
+ import type { VirtualShell } from "../VirtualShell";
2
2
  import type {
3
3
  CommandContext,
4
4
  CommandMode,
5
5
  CommandResult,
6
6
  ShellModule,
7
- } from "../../types/commands";
7
+ } from "../types/commands";
8
8
  import { adduserCommand } from "./adduser";
9
9
  import { catCommand } from "./cat";
10
10
  import { cdCommand } from "./cd";
@@ -142,6 +142,9 @@ export function getCommandNames(): string[] {
142
142
  }
143
143
 
144
144
  export function resolveModule(name: string): ShellModule | undefined {
145
+ if (!cachedCommandNames) {
146
+ buildCache();
147
+ }
145
148
  return commandRegistry.get(name.toLowerCase());
146
149
  }
147
150
 
@@ -213,8 +216,8 @@ export async function runCommand(
213
216
  }
214
217
 
215
218
  if (trimmed.includes("|") || trimmed.includes(">") || trimmed.includes("<")) {
216
- const { parseShellPipeline } = await import("../shellParser");
217
- const { executePipeline } = await import("../../SSHMimic/executor");
219
+ const { parseShellPipeline } = await import("../VirtualShell/shellParser");
220
+ const { executePipeline } = await import("../SSHMimic/executor");
218
221
 
219
222
  const pipeline = parseShellPipeline(trimmed);
220
223
  if (!pipeline.isValid) {
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getArg, ifFlag } from "./command-helpers";
3
3
  import { assertPathAccess, joinListWithType, resolvePath } from "./helpers";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getArg } from "./command-helpers";
3
3
  import { assertPathAccess, resolvePath } from "./helpers";
4
4
 
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import type { ShellModule } from "../../types/commands";
2
+ import type { ShellModule } from "../types/commands";
3
3
  import { assertPathAccess, resolvePath } from "./helpers";
4
4
 
5
5
  export const nanoCommand: ShellModule = {
@@ -1,5 +1,5 @@
1
- import { buildNeofetchOutput } from "../../../modules/neofetch";
2
- import type { ShellModule } from "../../types/commands";
1
+ import { buildNeofetchOutput } from "../../modules/neofetch";
2
+ import type { ShellModule } from "../types/commands";
3
3
  import { ifFlag } from "./command-helpers";
4
4
  import { getAllEnvVars } from "./set";
5
5
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const pwdCommand: ShellModule = {
4
4
  name: "pwd",
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getArg, ifFlag } from "./command-helpers";
3
3
  import { assertPathAccess, resolvePath } from "./helpers";
4
4
 
@@ -1,5 +1,5 @@
1
1
  /** biome-ignore-all lint/style/useNamingConvention: env variables */
2
- import type { ShellModule } from "../../types/commands";
2
+ import type { ShellModule } from "../types/commands";
3
3
  import { getArg } from "./command-helpers";
4
4
 
5
5
  // Simple in-memory environment variables store
@@ -1,4 +1,4 @@
1
- import type { CommandContext, ShellModule } from "../../types/commands";
1
+ import type { CommandContext, ShellModule } from "../types/commands";
2
2
  import { getArg, getFlag } from "./command-helpers";
3
3
  import { runCommand } from "./index";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getArg } from "./command-helpers";
3
3
 
4
4
  export const suCommand: ShellModule = {
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { parseArgs } from "./command-helpers";
3
3
  import { runCommand } from "./index";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { assertPathAccess, resolvePath } from "./helpers";
3
3
 
4
4
  export const touchCommand: ShellModule = {
@@ -13,7 +13,7 @@ export const touchCommand: ShellModule = {
13
13
  const target = resolvePath(cwd, file);
14
14
  assertPathAccess(authUser, target, "touch");
15
15
  if (!shell.vfs.exists(target)) {
16
- shell.vfs.writeFile(target, "");
16
+ shell.writeFileAsUser(authUser, target, "");
17
17
  }
18
18
  }
19
19
  return { exitCode: 0 };
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { getArg } from "./command-helpers";
3
3
  import { assertPathAccess, resolvePath } from "./helpers";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
  import { setEnvVar } from "./set";
3
3
 
4
4
  export const unsetCommand: ShellModule = {
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import type { ShellModule } from "../../types/commands";
5
+ import type { ShellModule } from "../types/commands";
6
6
  import { ifFlag, parseArgs } from "./command-helpers";
7
7
  import {
8
8
  assertPathAccess,
@@ -131,7 +131,7 @@ export const wgetCommand: ShellModule = {
131
131
  const content = await readFile(tempFile, "utf8");
132
132
  const target = resolvePath(cwd, outputPath || stripUrlFilename(url));
133
133
  assertPathAccess(authUser, target, "wget");
134
- shell.vfs.writeFile(target, content);
134
+ shell.writeFileAsUser(authUser, target, content);
135
135
 
136
136
  return {
137
137
  stdout: `saved ${target}`,
@@ -1,5 +1,5 @@
1
- import { formatLoginDate } from "../../SSHMimic/loginFormat";
2
- import type { ShellModule } from "../../types/commands";
1
+ import { formatLoginDate } from "../SSHMimic/loginFormat";
2
+ import type { ShellModule } from "../types/commands";
3
3
 
4
4
  export const whoCommand: ShellModule = {
5
5
  name: "who",
@@ -1,4 +1,4 @@
1
- import type { ShellModule } from "../../types/commands";
1
+ import type { ShellModule } from "../types/commands";
2
2
 
3
3
  export const whoamiCommand: ShellModule = {
4
4
  name: "whoami",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SshClient } from "./SSHMimic/client";
1
+ import { SshClient } from "./SSHClient";
2
2
  import { SshMimic } from "./SSHMimic/index";
3
3
  import VirtualFileSystem from "./VirtualFileSystem";
4
4
  import { VirtualShell } from "./VirtualShell";
@@ -41,4 +41,4 @@ export {
41
41
  getArg,
42
42
  getFlag,
43
43
  ifFlag,
44
- } from "./VirtualShell/commands/command-helpers";
44
+ } from "./commands/command-helpers";
@@ -3,7 +3,7 @@ import {
3
3
  getArg,
4
4
  getFlag,
5
5
  ifFlag,
6
- } from "../src/VirtualShell/commands/command-helpers";
6
+ } from "../src/commands/command-helpers";
7
7
 
8
8
  describe("command-helpers", () => {
9
9
  test("ifFlag detects plain and inline flag forms", () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { assertPathAccess } from "../src/VirtualShell/commands/helpers";
2
+ import { assertPathAccess } from "../src/commands/helpers";
3
3
 
4
4
  describe("assertPathAccess", () => {
5
5
  test("blocks non-root access to auth store", () => {
@@ -39,3 +39,63 @@ describe("VirtualUserManager auto sudo", () => {
39
39
  });
40
40
  });
41
41
  });
42
+
43
+ describe("VirtualUserManager quotas", () => {
44
+ test("enforces quota for writes inside user home", async () => {
45
+ await withTempVfs(async (vfs) => {
46
+ const users = new VirtualUserManager(vfs, "root-pass");
47
+ await users.initialize();
48
+ await users.addUser("alice", "alice-pass");
49
+ const startingUsage = users.getUsageBytes("alice");
50
+ await users.setQuotaBytes("alice", startingUsage + 5);
51
+
52
+ expect(() => {
53
+ users.assertWriteWithinQuota("alice", "/home/alice/note.txt", "hello");
54
+ }).not.toThrow();
55
+
56
+ vfs.writeFile("/home/alice/note.txt", "hello");
57
+
58
+ expect(() => {
59
+ users.assertWriteWithinQuota(
60
+ "alice",
61
+ "/home/alice/note.txt",
62
+ "this exceeds the configured quota",
63
+ );
64
+ }).toThrow("quota exceeded for 'alice'");
65
+ });
66
+ });
67
+
68
+ test("does not enforce home quota outside user home", async () => {
69
+ await withTempVfs(async (vfs) => {
70
+ const users = new VirtualUserManager(vfs, "root-pass");
71
+ await users.initialize();
72
+ await users.addUser("bob", "bob-pass");
73
+ await users.setQuotaBytes("bob", 1);
74
+
75
+ expect(() => {
76
+ users.assertWriteWithinQuota("bob", "/tmp/shared.txt", "large-content");
77
+ }).not.toThrow();
78
+ });
79
+ });
80
+
81
+ test("clearQuota removes enforced limit", async () => {
82
+ await withTempVfs(async (vfs) => {
83
+ const users = new VirtualUserManager(vfs, "root-pass");
84
+ await users.initialize();
85
+ await users.addUser("charlie", "charlie-pass");
86
+ await users.setQuotaBytes("charlie", 2);
87
+
88
+ expect(users.getQuotaBytes("charlie")).toBe(2);
89
+ await users.clearQuota("charlie");
90
+ expect(users.getQuotaBytes("charlie")).toBeNull();
91
+
92
+ expect(() => {
93
+ users.assertWriteWithinQuota(
94
+ "charlie",
95
+ "/home/charlie/file.txt",
96
+ "long-content",
97
+ );
98
+ }).not.toThrow();
99
+ });
100
+ });
101
+ });