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.
- package/.vscode/settings.json +18 -0
- package/README.md +45 -5
- package/modules/shellInteractive.ts +45 -0
- package/modules/shellRuntime.ts +76 -0
- package/package.json +1 -1
- package/src/{SSHMimic/client.ts → SSHClient/index.ts} +2 -2
- package/src/SSHMimic/exec.ts +1 -1
- package/src/SSHMimic/executor.ts +8 -8
- package/src/VirtualFileSystem/index.ts +26 -0
- package/src/VirtualShell/index.ts +17 -1
- package/src/VirtualShell/shell.ts +19 -107
- package/src/VirtualShell/shellParser.ts +32 -7
- package/src/VirtualUserManager/index.ts +149 -0
- package/src/{VirtualShell/commands → commands}/adduser.ts +1 -1
- package/src/{VirtualShell/commands → commands}/cat.ts +1 -1
- package/src/{VirtualShell/commands → commands}/cd.ts +1 -1
- package/src/{VirtualShell/commands → commands}/clear.ts +1 -1
- package/src/{VirtualShell/commands → commands}/curl.ts +2 -2
- package/src/{VirtualShell/commands → commands}/deluser.ts +1 -1
- package/src/{VirtualShell/commands → commands}/echo.ts +1 -1
- package/src/{VirtualShell/commands → commands}/env.ts +1 -1
- package/src/{VirtualShell/commands → commands}/exit.ts +1 -1
- package/src/{VirtualShell/commands → commands}/export.ts +1 -1
- package/src/{VirtualShell/commands → commands}/grep.ts +1 -1
- package/src/{VirtualShell/commands → commands}/help.ts +1 -1
- package/src/{VirtualShell/commands → commands}/helpers.ts +1 -1
- package/src/{VirtualShell/commands → commands}/hostname.ts +1 -1
- package/src/{VirtualShell/commands → commands}/htop.ts +1 -1
- package/src/{VirtualShell/commands → commands}/index.ts +7 -4
- package/src/{VirtualShell/commands → commands}/ls.ts +1 -1
- package/src/{VirtualShell/commands → commands}/mkdir.ts +1 -1
- package/src/{VirtualShell/commands → commands}/nano.ts +1 -1
- package/src/{VirtualShell/commands → commands}/neofetch.ts +2 -2
- package/src/{VirtualShell/commands → commands}/pwd.ts +1 -1
- package/src/{VirtualShell/commands → commands}/rm.ts +1 -1
- package/src/{VirtualShell/commands → commands}/set.ts +1 -1
- package/src/{VirtualShell/commands → commands}/sh.ts +1 -1
- package/src/{VirtualShell/commands → commands}/su.ts +1 -1
- package/src/{VirtualShell/commands → commands}/sudo.ts +1 -1
- package/src/{VirtualShell/commands → commands}/touch.ts +2 -2
- package/src/{VirtualShell/commands → commands}/tree.ts +1 -1
- package/src/{VirtualShell/commands → commands}/unset.ts +1 -1
- package/src/{VirtualShell/commands → commands}/wget.ts +2 -2
- package/src/{VirtualShell/commands → commands}/who.ts +2 -2
- package/src/{VirtualShell/commands → commands}/whoami.ts +1 -1
- package/src/index.ts +2 -2
- package/tests/command-helpers.test.ts +1 -1
- package/tests/helpers.test.ts +1 -1
- package/tests/users.test.ts +60 -0
- /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
|
|
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
|
-
- [
|
|
1362
|
-
- [
|
|
1363
|
-
- [
|
|
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
|
-
- [
|
|
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
|
@@ -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
|
-
|
|
157
|
+
this.shell.writeFileAsUser(this.username, path, content);
|
|
158
158
|
return { stdout: `File '${path}' written`, exitCode: 0 };
|
|
159
159
|
} catch (error) {
|
|
160
160
|
return {
|
package/src/SSHMimic/exec.ts
CHANGED
|
@@ -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
|
package/src/SSHMimic/executor.ts
CHANGED
|
@@ -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.
|
|
93
|
+
shell.writeFileAsUser(authUser, outputPath, existing + output);
|
|
94
94
|
} catch {
|
|
95
|
-
shell.
|
|
95
|
+
shell.writeFileAsUser(authUser, outputPath, output);
|
|
96
96
|
}
|
|
97
97
|
} else {
|
|
98
|
-
shell.
|
|
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.
|
|
168
|
+
shell.writeFileAsUser(authUser, outputPath, existing + output);
|
|
169
169
|
} catch {
|
|
170
|
-
shell.
|
|
170
|
+
shell.writeFileAsUser(authUser, outputPath, output);
|
|
171
171
|
}
|
|
172
172
|
} else {
|
|
173
|
-
shell.
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
64
|
-
|
|
76
|
+
if (inQuotes) {
|
|
77
|
+
return {
|
|
78
|
+
tokens: [],
|
|
79
|
+
error: "Syntax error: unterminated quote",
|
|
80
|
+
};
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
|
|
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 "
|
|
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.
|
|
42
|
+
shell.writeFileAsUser(authUser, target, result.stdout);
|
|
43
43
|
return {
|
|
44
44
|
stderr: result.stderr
|
|
45
45
|
? normalizeTerminalOutput(result.stderr)
|
|
@@ -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 "
|
|
3
|
+
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
4
4
|
|
|
5
5
|
const PROTECTED_PREFIXES = ["/virtual-env-js/.auth"] as const;
|
|
6
6
|
|
|
@@ -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 "
|
|
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("
|
|
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,5 +1,5 @@
|
|
|
1
|
-
import { buildNeofetchOutput } from "
|
|
2
|
-
import type { ShellModule } from "
|
|
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,5 +1,5 @@
|
|
|
1
1
|
/** biome-ignore-all lint/style/useNamingConvention: env variables */
|
|
2
|
-
import type { ShellModule } from "
|
|
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 { ShellModule } from "
|
|
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.
|
|
16
|
+
shell.writeFileAsUser(authUser, target, "");
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
return { exitCode: 0 };
|
|
@@ -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 "
|
|
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.
|
|
134
|
+
shell.writeFileAsUser(authUser, target, content);
|
|
135
135
|
|
|
136
136
|
return {
|
|
137
137
|
stdout: `saved ${target}`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { formatLoginDate } from "
|
|
2
|
-
import type { ShellModule } from "
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SshClient } from "./
|
|
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 "./
|
|
44
|
+
} from "./commands/command-helpers";
|
package/tests/helpers.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { assertPathAccess } from "../src/
|
|
2
|
+
import { assertPathAccess } from "../src/commands/helpers";
|
|
3
3
|
|
|
4
4
|
describe("assertPathAccess", () => {
|
|
5
5
|
test("blocks non-root access to auth store", () => {
|
package/tests/users.test.ts
CHANGED
|
@@ -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
|
+
});
|
|
File without changes
|