typescript-virtual-container 1.0.1 → 1.0.4

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.
@@ -0,0 +1,201 @@
1
+ import type { CommandMode, CommandResult } from "../types/commands";
2
+ import type { Pipeline, PipelineCommand } from "../types/pipeline";
3
+ import type VirtualFileSystem from "../VirtualFileSystem";
4
+ import { runCommand as runSingleCommand } from "./commands";
5
+ import { resolvePath } from "./commands/helpers";
6
+ import type { VirtualUserManager } from "./users";
7
+
8
+ /**
9
+ * Execute a parsed pipeline, chaining commands and handling redirections.
10
+ * Manages stdout/stderr flow between commands and file I/O.
11
+ */
12
+ export async function executePipeline(
13
+ pipeline: Pipeline,
14
+ authUser: string,
15
+ hostname: string,
16
+ users: VirtualUserManager,
17
+ mode: CommandMode,
18
+ cwd: string,
19
+ vfs: VirtualFileSystem,
20
+ ): Promise<CommandResult> {
21
+ if (pipeline.commands.length === 0) {
22
+ return { exitCode: 0 };
23
+ }
24
+
25
+ if (pipeline.commands.length === 1) {
26
+ // Single command with possible redirections
27
+ return executeSingleCommandWithRedirections(
28
+ pipeline.commands[0] as PipelineCommand,
29
+ authUser,
30
+ hostname,
31
+ users,
32
+ mode,
33
+ cwd,
34
+ vfs,
35
+ );
36
+ }
37
+
38
+ // Multiple commands in a pipeline
39
+ return executePipelineChain(
40
+ pipeline.commands as PipelineCommand[],
41
+ authUser,
42
+ hostname,
43
+ users,
44
+ mode,
45
+ cwd,
46
+ vfs,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Execute a single command with input/output redirections
52
+ */
53
+ async function executeSingleCommandWithRedirections(
54
+ cmd: PipelineCommand,
55
+ authUser: string,
56
+ hostname: string,
57
+ users: VirtualUserManager,
58
+ mode: CommandMode,
59
+ cwd: string,
60
+ vfs: VirtualFileSystem,
61
+ ): Promise<CommandResult> {
62
+ // Prepare input if input file specified
63
+ let stdin: string | undefined;
64
+ if (cmd.inputFile) {
65
+ const inputPath = resolvePath(cwd, cmd.inputFile);
66
+ try {
67
+ stdin = vfs.readFile(inputPath);
68
+ } catch {
69
+ return {
70
+ stderr: `cat: ${cmd.inputFile}: No such file or directory`,
71
+ exitCode: 1,
72
+ };
73
+ }
74
+ }
75
+
76
+ // Build raw input for the command
77
+ const rawInput = [cmd.name, ...cmd.args].join(" ");
78
+
79
+ // Run the command with potential input
80
+ const result = await runSingleCommand(
81
+ rawInput,
82
+ authUser,
83
+ hostname,
84
+ users,
85
+ mode,
86
+ cwd,
87
+ vfs,
88
+ stdin,
89
+ );
90
+
91
+ // Handle output redirection
92
+ if (cmd.outputFile) {
93
+ const outputPath = resolvePath(cwd, cmd.outputFile);
94
+ const output = result.stdout || "";
95
+ try {
96
+ if (cmd.appendOutput) {
97
+ try {
98
+ const existing = vfs.readFile(outputPath);
99
+ vfs.writeFile(outputPath, existing + output);
100
+ } catch {
101
+ vfs.writeFile(outputPath, output);
102
+ }
103
+ } else {
104
+ vfs.writeFile(outputPath, output);
105
+ }
106
+ return { ...result, stdout: "" };
107
+ } catch {
108
+ return {
109
+ ...result,
110
+ stderr: `Failed to write to ${cmd.outputFile}`,
111
+ exitCode: 1,
112
+ };
113
+ }
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Execute a chain of commands connected by pipes
121
+ */
122
+ async function executePipelineChain(
123
+ commands: PipelineCommand[],
124
+ authUser: string,
125
+ hostname: string,
126
+ users: VirtualUserManager,
127
+ mode: CommandMode,
128
+ cwd: string,
129
+ vfs: VirtualFileSystem,
130
+ ): Promise<CommandResult> {
131
+ let currentOutput = "";
132
+ let exitCode = 0;
133
+
134
+ for (let i = 0; i < commands.length; i++) {
135
+ const cmd = commands[i] as PipelineCommand;
136
+
137
+ // Handle input file for first command
138
+ if (i === 0 && cmd.inputFile) {
139
+ const inputPath = resolvePath(cwd, cmd.inputFile);
140
+ try {
141
+ currentOutput = vfs.readFile(inputPath);
142
+ } catch {
143
+ return {
144
+ stderr: `cat: ${cmd.inputFile}: No such file or directory`,
145
+ exitCode: 1,
146
+ };
147
+ }
148
+ }
149
+
150
+ // Build raw input
151
+ const rawInput = [cmd.name, ...cmd.args].join(" ");
152
+
153
+ // Create a modified context that might accept stdin
154
+ // For now, we'll append input as an additional arg for commands that support it
155
+ const result = await runSingleCommand(
156
+ rawInput,
157
+ authUser,
158
+ hostname,
159
+ users,
160
+ mode,
161
+ cwd,
162
+ vfs,
163
+ currentOutput,
164
+ );
165
+
166
+ exitCode = result.exitCode ?? 0;
167
+
168
+ // Handle output redirection (only for last command)
169
+ if (i === commands.length - 1 && cmd.outputFile) {
170
+ const outputPath = resolvePath(cwd, cmd.outputFile);
171
+ const output = result.stdout || "";
172
+ try {
173
+ if (cmd.appendOutput) {
174
+ try {
175
+ const existing = vfs.readFile(outputPath);
176
+ vfs.writeFile(outputPath, existing + output);
177
+ } catch {
178
+ vfs.writeFile(outputPath, output);
179
+ }
180
+ } else {
181
+ vfs.writeFile(outputPath, output);
182
+ }
183
+ currentOutput = "";
184
+ } catch {
185
+ return {
186
+ stderr: `Failed to write to ${cmd.outputFile}`,
187
+ exitCode: 1,
188
+ };
189
+ }
190
+ } else {
191
+ // Pass output to next command
192
+ currentOutput = result.stdout || "";
193
+ }
194
+
195
+ if (result.stderr && exitCode !== 0) {
196
+ return { stderr: result.stderr, exitCode };
197
+ }
198
+ }
199
+
200
+ return { stdout: currentOutput, exitCode };
201
+ }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { Server as SshServer } from "ssh2";
2
3
  import VirtualFileSystem from "../VirtualFileSystem";
3
4
  import { runExec } from "./exec";
@@ -5,6 +6,28 @@ import { loadOrCreateHostKey } from "./hostKey";
5
6
  import { startShell } from "./shell";
6
7
  import { VirtualUserManager } from "./users";
7
8
 
9
+ function resolveRootPassword(): string {
10
+ const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
11
+ if (configured && configured.trim().length > 0) {
12
+ return configured;
13
+ }
14
+
15
+ const generated = randomBytes(18).toString("base64url");
16
+ console.warn(
17
+ `[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
18
+ );
19
+ return generated;
20
+ }
21
+
22
+ function resolveAutoSudoForNewUsers(): boolean {
23
+ const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
24
+ if (!configured) {
25
+ return true;
26
+ }
27
+
28
+ return !["0", "false", "no", "off"].includes(configured.toLowerCase());
29
+ }
30
+
8
31
  /**
9
32
  * SSH server wrapper that exposes virtual shell and exec sessions.
10
33
  *
@@ -52,7 +75,8 @@ class SshMimic {
52
75
  await this.vfs.restoreMirror();
53
76
  this.users = new VirtualUserManager(
54
77
  this.vfs,
55
- process.env.SSH_MIMIC_ROOT_PASSWORD ?? "root",
78
+ resolveRootPassword(),
79
+ resolveAutoSudoForNewUsers(),
56
80
  );
57
81
  await this.users.initialize();
58
82
 
@@ -0,0 +1,203 @@
1
+ import type { Pipeline, PipelineCommand } from "../types/pipeline";
2
+
3
+ /** Parse a shell command line into a structured pipeline */
4
+ export function parseShellPipeline(rawInput: string): Pipeline {
5
+ const trimmed = rawInput.trim();
6
+
7
+ if (!trimmed) {
8
+ return { commands: [], isValid: true };
9
+ }
10
+
11
+ const commands: PipelineCommand[] = [];
12
+ const pipeTokens = tokenizePipeline(trimmed);
13
+
14
+ for (const token of pipeTokens) {
15
+ const cmd = parseCommandWithRedirections(token);
16
+ if (!cmd.isValid) {
17
+ return {
18
+ commands: [],
19
+ isValid: false,
20
+ error: cmd.error,
21
+ };
22
+ }
23
+ if (cmd.command) {
24
+ commands.push(cmd.command);
25
+ }
26
+ }
27
+
28
+ return { commands, isValid: true };
29
+ }
30
+
31
+ /** Tokenize input by pipes, respecting quoted strings */
32
+ function tokenizePipeline(input: string): string[] {
33
+ const tokens: string[] = [];
34
+ let current = "";
35
+ let inQuotes = false;
36
+ let quoteChar = "";
37
+ let i = 0;
38
+
39
+ while (i < input.length) {
40
+ const ch = input[i];
41
+
42
+ if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
43
+ if (!inQuotes) {
44
+ inQuotes = true;
45
+ quoteChar = ch;
46
+ } else if (ch === quoteChar) {
47
+ inQuotes = false;
48
+ }
49
+ current += ch;
50
+ i++;
51
+ } else if (ch === "|" && !inQuotes) {
52
+ if (current.trim()) {
53
+ tokens.push(current.trim());
54
+ }
55
+ current = "";
56
+ i++;
57
+ } else {
58
+ current += ch;
59
+ i++;
60
+ }
61
+ }
62
+
63
+ if (current.trim()) {
64
+ tokens.push(current.trim());
65
+ }
66
+
67
+ return tokens;
68
+ }
69
+
70
+ interface ParseResult {
71
+ command?: PipelineCommand;
72
+ isValid: boolean;
73
+ error?: string;
74
+ }
75
+
76
+ /** Parse a single command with its redirections (>, >>, <) */
77
+ function parseCommandWithRedirections(token: string): ParseResult {
78
+ const parts = tokenizeCommand(token);
79
+
80
+ if (parts.length === 0) {
81
+ return { isValid: true };
82
+ }
83
+
84
+ const cmdParts: string[] = [];
85
+ let inputFile: string | undefined;
86
+ let outputFile: string | undefined;
87
+ let appendOutput = false;
88
+
89
+ let i = 0;
90
+ while (i < parts.length) {
91
+ const part = parts[i] as string;
92
+
93
+ if (part === "<") {
94
+ i++;
95
+ if (i >= parts.length) {
96
+ return {
97
+ isValid: false,
98
+ error: "Syntax error: expected filename after <",
99
+ };
100
+ }
101
+ inputFile = parts[i];
102
+ i++;
103
+ } else if (part === ">>") {
104
+ i++;
105
+ if (i >= parts.length) {
106
+ return {
107
+ isValid: false,
108
+ error: "Syntax error: expected filename after >>",
109
+ };
110
+ }
111
+ outputFile = parts[i];
112
+ appendOutput = true;
113
+ i++;
114
+ } else if (part === ">") {
115
+ i++;
116
+ if (i >= parts.length) {
117
+ return {
118
+ isValid: false,
119
+ error: "Syntax error: expected filename after >",
120
+ };
121
+ }
122
+ outputFile = parts[i];
123
+ appendOutput = false;
124
+ i++;
125
+ } else {
126
+ cmdParts.push(part);
127
+ i++;
128
+ }
129
+ }
130
+
131
+ if (cmdParts.length === 0) {
132
+ return { isValid: true };
133
+ }
134
+
135
+ const name = (cmdParts[0] as string).toLowerCase();
136
+ const args = cmdParts.slice(1);
137
+
138
+ return {
139
+ command: {
140
+ name,
141
+ args,
142
+ inputFile,
143
+ outputFile,
144
+ appendOutput,
145
+ },
146
+ isValid: true,
147
+ };
148
+ }
149
+
150
+ /** Tokenize a command, respecting quotes and handling >> vs > */
151
+ function tokenizeCommand(input: string): string[] {
152
+ const tokens: string[] = [];
153
+ let current = "";
154
+ let inQuotes = false;
155
+ let quoteChar = "";
156
+ let i = 0;
157
+
158
+ while (i < input.length) {
159
+ const ch = input[i];
160
+ const next = input[i + 1];
161
+
162
+ // Handle quotes
163
+ if ((ch === '"' || ch === "'") && (i === 0 || input[i - 1] !== "\\")) {
164
+ if (!inQuotes) {
165
+ inQuotes = true;
166
+ quoteChar = ch;
167
+ } else if (ch === quoteChar) {
168
+ inQuotes = false;
169
+ quoteChar = "";
170
+ } else {
171
+ current += ch;
172
+ }
173
+ i++;
174
+ } else if (ch === " " && !inQuotes) {
175
+ if (current) {
176
+ tokens.push(current);
177
+ current = "";
178
+ }
179
+ i++;
180
+ } else if ((ch === ">" || ch === "<") && !inQuotes) {
181
+ if (current) {
182
+ tokens.push(current);
183
+ current = "";
184
+ }
185
+ if (ch === ">" && next === ">") {
186
+ tokens.push(">>");
187
+ i += 2;
188
+ } else {
189
+ tokens.push(ch);
190
+ i++;
191
+ }
192
+ } else {
193
+ current += ch;
194
+ i++;
195
+ }
196
+ }
197
+
198
+ if (current) {
199
+ tokens.push(current);
200
+ }
201
+
202
+ return tokens;
203
+ }
@@ -31,6 +31,7 @@ export interface VirtualActiveSession {
31
31
  export class VirtualUserManager {
32
32
  private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
33
33
  private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
34
+ private readonly authDirPath = "/virtual-env-js/.auth";
34
35
  private readonly users = new Map<string, VirtualUserRecord>();
35
36
  private readonly sudoers = new Set<string>();
36
37
  private readonly activeSessions = new Map<string, VirtualActiveSession>();
@@ -45,6 +46,7 @@ export class VirtualUserManager {
45
46
  constructor(
46
47
  private readonly vfs: VirtualFileSystem,
47
48
  private readonly defaultRootPassword: string = "root",
49
+ private readonly autoSudoForNewUsers: boolean = true,
48
50
  ) {}
49
51
 
50
52
  /**
@@ -54,12 +56,7 @@ export class VirtualUserManager {
54
56
  this.loadFromVfs();
55
57
  this.loadSudoersFromVfs();
56
58
 
57
- if (!this.users.has("root")) {
58
- this.users.set(
59
- "root",
60
- this.createRecord("root", this.defaultRootPassword),
61
- );
62
- }
59
+ this.users.set("root", this.createRecord("root", this.defaultRootPassword));
63
60
 
64
61
  this.sudoers.add("root");
65
62
 
@@ -97,7 +94,9 @@ export class VirtualUserManager {
97
94
  }
98
95
 
99
96
  this.users.set(username, this.createRecord(username, password));
100
- this.sudoers.add(username);
97
+ if (this.autoSudoForNewUsers) {
98
+ this.sudoers.add(username);
99
+ }
101
100
  const homePath = `/home/${username}`;
102
101
  if (!this.vfs.exists(homePath)) {
103
102
  this.vfs.mkdir(homePath, 0o755);
@@ -290,6 +289,10 @@ export class VirtualUserManager {
290
289
  }
291
290
 
292
291
  private async persist(): Promise<void> {
292
+ if (!this.vfs.exists(this.authDirPath)) {
293
+ this.vfs.mkdir(this.authDirPath, 0o700);
294
+ }
295
+
293
296
  const content = Array.from(this.users.values())
294
297
  .sort((left, right) => left.username.localeCompare(right.username))
295
298
  .map((record) =>
@@ -300,11 +303,13 @@ export class VirtualUserManager {
300
303
  this.vfs.writeFile(
301
304
  this.usersPath,
302
305
  content.length > 0 ? `${content}\n` : "",
306
+ { mode: 0o600 },
303
307
  );
304
308
  const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
305
309
  this.vfs.writeFile(
306
310
  this.sudoersPath,
307
311
  sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
312
+ { mode: 0o600 },
308
313
  );
309
314
  await this.vfs.flushMirror();
310
315
  }
@@ -326,6 +331,10 @@ export class VirtualUserManager {
326
331
  if (!username || username.trim() === "") {
327
332
  throw new Error("invalid username");
328
333
  }
334
+
335
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/i.test(username)) {
336
+ throw new Error("invalid username");
337
+ }
329
338
  }
330
339
 
331
340
  private validatePassword(password: string): void {
@@ -76,6 +76,8 @@ export interface CommandContext {
76
76
  mode: CommandMode;
77
77
  /** Tokenized arguments excluding command name. */
78
78
  args: string[];
79
+ /** Optional stdin payload (used by pipes/redirections). */
80
+ stdin?: string;
79
81
  /** Current working directory for command execution. */
80
82
  cwd: string;
81
83
  /** Virtual filesystem instance for IO operations. */
@@ -0,0 +1,23 @@
1
+ /** Represents a single command in a pipeline. */
2
+ export interface PipelineCommand {
3
+ /** Command name */
4
+ name: string;
5
+ /** Command arguments */
6
+ args: string[];
7
+ /** Input redirection file path (< file) */
8
+ inputFile?: string;
9
+ /** Output redirection file path (> file) */
10
+ outputFile?: string;
11
+ /** Append to output file (>> file) */
12
+ appendOutput?: boolean;
13
+ }
14
+
15
+ /** Represents a parsed shell pipeline */
16
+ export interface Pipeline {
17
+ /** List of commands in the pipeline */
18
+ commands: PipelineCommand[];
19
+ /** Whether this is a valid pipeline */
20
+ isValid: boolean;
21
+ /** Error message if parsing failed */
22
+ error?: string;
23
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { assertPathAccess } from "../src/SSHMimic/commands/helpers";
3
+
4
+ describe("assertPathAccess", () => {
5
+ test("blocks non-root access to auth store", () => {
6
+ expect(() =>
7
+ assertPathAccess("alice", "/virtual-env-js/.auth/htpasswd", "cat"),
8
+ ).toThrow("cat: permission denied: /virtual-env-js/.auth/htpasswd");
9
+ });
10
+
11
+ test("allows root access to auth store", () => {
12
+ expect(() =>
13
+ assertPathAccess("root", "/virtual-env-js/.auth/htpasswd", "cat"),
14
+ ).not.toThrow();
15
+ });
16
+
17
+ test("allows non-root access outside protected paths", () => {
18
+ expect(() =>
19
+ assertPathAccess("alice", "/home/alice/README.txt", "cat"),
20
+ ).not.toThrow();
21
+ });
22
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { VirtualUserManager } from "../src/SSHMimic/users";
6
+ import VirtualFileSystem from "../src/VirtualFileSystem";
7
+
8
+ async function withTempVfs(
9
+ run: (vfs: VirtualFileSystem) => Promise<void>,
10
+ ): Promise<void> {
11
+ const tempDir = await mkdtemp(join(tmpdir(), "virtual-env-js-test-"));
12
+ try {
13
+ const vfs = new VirtualFileSystem(tempDir);
14
+ await vfs.restoreMirror();
15
+ await run(vfs);
16
+ } finally {
17
+ await rm(tempDir, { recursive: true, force: true });
18
+ }
19
+ }
20
+
21
+ describe("VirtualUserManager auto sudo", () => {
22
+ test("adds new users to sudoers by default", async () => {
23
+ await withTempVfs(async (vfs) => {
24
+ const users = new VirtualUserManager(vfs, "root-pass");
25
+ await users.initialize();
26
+ await users.addUser("alice", "alice-pass");
27
+
28
+ expect(users.isSudoer("alice")).toBe(true);
29
+ });
30
+ });
31
+
32
+ test("does not auto-add sudoers when disabled", async () => {
33
+ await withTempVfs(async (vfs) => {
34
+ const users = new VirtualUserManager(vfs, "root-pass", false);
35
+ await users.initialize();
36
+ await users.addUser("bob", "bob-pass");
37
+
38
+ expect(users.isSudoer("bob")).toBe(false);
39
+ });
40
+ });
41
+ });
package/tsconfig.json CHANGED
@@ -26,6 +26,6 @@
26
26
  "noUnusedParameters": false,
27
27
  "noPropertyAccessFromIndexSignature": false,
28
28
 
29
- "types": ["node"]
29
+ "types": ["node", "bun"]
30
30
  }
31
31
  }