diff --git a/src/job.zig b/src/job.zig index fe35f4f..8164916 100644 --- a/src/job.zig +++ b/src/job.zig @@ -21,6 +21,28 @@ pub const Location = struct { }; pub fn runRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, argv: []const []const u8) ![][]const u8 { + try validateArgv(argv); + const command_preview = try commandPreviewAlloc(allocator, argv); + defer allocator.free(command_preview); + return runRowsWithPreviewAlloc(allocator, io, cwd, argv, "job", command_preview); +} + +pub fn shellRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, command: []const u8) ![][]const u8 { + try validateShellCommand(command); + const argv = [_][]const u8{ "sh", "-c", command }; + const command_preview = try sanitizeLineAlloc(allocator, command); + defer allocator.free(command_preview); + return runRowsWithPreviewAlloc(allocator, io, cwd, &argv, "terminal", command_preview); +} + +fn runRowsWithPreviewAlloc( + allocator: std.mem.Allocator, + io: std.Io, + cwd: []const u8, + argv: []const []const u8, + kind: []const u8, + command_preview: []const u8, +) ![][]const u8 { try validateCwd(cwd); try validateArgv(argv); @@ -30,9 +52,7 @@ pub fn runRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, a rows.deinit(allocator); } - const command_preview = try commandPreviewAlloc(allocator, argv); - defer allocator.free(command_preview); - try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:spawned:{s}", .{command_preview})); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:spawned:{s}", .{ kind, command_preview })); const result = std.process.run(allocator, io, .{ .argv = argv, @@ -40,16 +60,16 @@ pub fn runRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, a .stdout_limit = .limited(256 * 1024), .stderr_limit = .limited(256 * 1024), }) catch |err| { - try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:status:spawn_error_{s}", .{@errorName(err)})); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:status:spawn_error_{s}", .{ kind, @errorName(err) })); return rows.toOwnedSlice(allocator); }; defer allocator.free(result.stdout); defer allocator.free(result.stderr); - try appendTermRow(allocator, &rows, result.term); + try appendTermRow(allocator, &rows, kind, result.term); try appendOutputRows(allocator, &rows, "stdout", result.stdout); try appendOutputRows(allocator, &rows, "stderr", result.stderr); - if (rows.items.len == 2) try rows.append(allocator, try allocator.dupe(u8, "job:output_empty")); + if (rows.items.len == 2) try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:output_empty", .{kind})); return rows.toOwnedSlice(allocator); } @@ -108,6 +128,13 @@ fn validateArgv(argv: []const []const u8) !void { } } +fn validateShellCommand(command: []const u8) !void { + if (command.len == 0 or !std.unicode.utf8ValidateSlice(command)) return Error.InvalidCommand; + for (command) |byte| { + if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidCommand; + } +} + fn validatePath(path: []const u8) !void { if (path.len == 0 or !std.unicode.utf8ValidateSlice(path)) return Error.InvalidPath; if (std.mem.startsWith(u8, path, "/") or std.mem.indexOf(u8, path, "..") != null) return Error.InvalidPath; @@ -116,12 +143,12 @@ fn validatePath(path: []const u8) !void { } } -fn appendTermRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), term: std.process.Child.Term) !void { +fn appendTermRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), kind: []const u8, term: std.process.Child.Term) !void { const row = switch (term) { - .exited => |code| try std.fmt.allocPrint(allocator, "job:status:exit_{d}", .{code}), - .signal => |sig| try std.fmt.allocPrint(allocator, "job:status:signal_{d}", .{@intFromEnum(sig)}), - .stopped => |sig| try std.fmt.allocPrint(allocator, "job:status:stopped_{d}", .{@intFromEnum(sig)}), - .unknown => |code| try std.fmt.allocPrint(allocator, "job:status:unknown_{d}", .{code}), + .exited => |code| try std.fmt.allocPrint(allocator, "{s}:status:exit_{d}", .{ kind, code }), + .signal => |sig| try std.fmt.allocPrint(allocator, "{s}:status:signal_{d}", .{ kind, @intFromEnum(sig) }), + .stopped => |sig| try std.fmt.allocPrint(allocator, "{s}:status:stopped_{d}", .{ kind, @intFromEnum(sig) }), + .unknown => |code| try std.fmt.allocPrint(allocator, "{s}:status:unknown_{d}", .{ kind, code }), }; try rows.append(allocator, row); } @@ -196,3 +223,22 @@ test "adversarial: invalid commands rows and unparseable output are rejected" { try std.testing.expectError(Error.InvalidJobRow, locationFromRow("job:status:exit_1")); try std.testing.expectError(Error.InvalidPath, locationFromRow("stderr:../secret:1:1:nope")); } + +test "regular: shell terminal rows capture command output and exit status" { + const rows = try shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "printf terminal_ok"); + defer freeOwnedRows(std.testing.allocator, rows); + try std.testing.expect(rows.len >= 3); + try std.testing.expectEqualStrings("terminal:spawned:printf_terminal_ok", rows[0]); + try std.testing.expectEqualStrings("terminal:status:exit_0", rows[1]); + try std.testing.expectEqualStrings("stdout:terminal_ok", rows[2]); +} + +test "adversarial: shell terminal rejects invalid command and reports shell failures" { + try std.testing.expectError(Error.InvalidCommand, shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "")); + try std.testing.expectError(Error.InvalidCommand, shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "echo bad\n")); + + const rows = try shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "definitely-not-a-mim-command"); + defer freeOwnedRows(std.testing.allocator, rows); + try std.testing.expectEqualStrings("terminal:status:exit_127", rows[1]); + try std.testing.expect(std.mem.startsWith(u8, rows[2], "stderr:")); +} diff --git a/src/tui.zig b/src/tui.zig index 4d33dbb..eb95ac9 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -142,6 +142,10 @@ pub const Client = struct { if (std.mem.eql(u8, line, "job_status")) return self.openStaticJobRow("job-status", "job_status:idle"); if (std.mem.eql(u8, line, "job_cancel")) return self.openStaticJobRow("job-status", "job_cancel:no_running_job"); if (std.mem.startsWith(u8, line, "job_open_selected ")) return self.openSelectedJobLocation(line[18..]); + if (std.mem.startsWith(u8, line, "terminal_run ")) return self.openTerminalRun(line[13..]); + if (std.mem.eql(u8, line, "terminal_status")) return self.openStaticJobRow("terminal-status", "terminal_status:idle"); + if (std.mem.eql(u8, line, "terminal_cancel")) return self.openStaticJobRow("terminal-status", "terminal_cancel:no_running_terminal"); + if (std.mem.eql(u8, line, "terminal_exit")) return self.closeTerminalPanel(); if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line); @@ -374,23 +378,34 @@ pub const Client = struct { fn openJobRun(self: *Client, cwd_and_command: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; - const first_space = std.mem.indexOfScalar(u8, cwd_and_command, ' ') orelse return Error.ProtocolRejected; - const cwd = cwd_and_command[0..first_space]; - const command_line = std.mem.trim(u8, cwd_and_command[first_space + 1 ..], " "); - if (command_line.len == 0) return Error.ProtocolRejected; + const split = splitCwdAndCommand(cwd_and_command) orelse return Error.ProtocolRejected; var argv = std.ArrayList([]const u8).empty; defer argv.deinit(self.allocator); - var parts = std.mem.splitScalar(u8, command_line, ' '); + var parts = std.mem.splitScalar(u8, split.command, ' '); while (parts.next()) |part| { if (part.len == 0) continue; try argv.append(self.allocator, part); } - const rows = job_mod.runRowsAlloc(self.allocator, io, cwd, argv.items) catch return Error.ProtocolRejected; + const rows = job_mod.runRowsAlloc(self.allocator, io, split.cwd, argv.items) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("job-output", rows); self.message = null; } + fn openTerminalRun(self: *Client, cwd_and_command: []const u8) !void { + const io = self.io orelse return Error.ProtocolRejected; + const split = splitCwdAndCommand(cwd_and_command) orelse return Error.ProtocolRejected; + const rows = job_mod.shellRowsAlloc(self.allocator, io, split.cwd, split.command) catch return Error.ProtocolRejected; + defer freeOwnedRows(self.allocator, rows); + try self.session.openListPanel("terminal-output", rows); + self.message = null; + } + + fn closeTerminalPanel(self: *Client) !void { + try self.session.closePanel(); + self.message = null; + } + fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void { try self.session.openListPanel(title, &.{row}); self.message = null; @@ -636,6 +651,14 @@ fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void { allocator.free(rows); } +fn splitCwdAndCommand(cwd_and_command: []const u8) ?struct { cwd: []const u8, command: []const u8 } { + const first_space = std.mem.indexOfScalar(u8, cwd_and_command, ' ') orelse return null; + const cwd = cwd_and_command[0..first_space]; + const command = std.mem.trim(u8, cwd_and_command[first_space + 1 ..], " "); + if (cwd.len == 0 or command.len == 0) return null; + return .{ .cwd = cwd, .command = command }; +} + test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" { const trace = \\open abc @@ -1379,3 +1402,60 @@ test "adversarial: bad job command is visible and failed job open does not corru const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } + +test "regular: terminal escape hatch runs shell command exits and returns editor" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 6 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("open safe_editor"); + try client.handleTraceLine("terminal_run . printf terminal_ok"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-output") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "terminal:status:exit_0") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:terminal_ok") != null); + } + + try client.handleTraceLine("terminal_exit"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-output") == null); + try std.testing.expect(std.mem.indexOf(u8, frame, "safe_editor") != null); +} + +test "regular: terminal status and cancel are honest foreground lifecycle rows" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 5 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("terminal_status"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-status") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "terminal_status:idle") != null); + } + + try client.handleTraceLine("terminal_cancel"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "terminal_cancel:no_running_terminal") != null); + } +} + +test "adversarial: terminal command rejection does not corrupt editor" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 5 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("open safe"); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("terminal_run . ")); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("safe", snap.bytes); + + try client.handleTraceLine("terminal_run . definitely-not-a-mim-command"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "terminal:status:exit_127") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "stderr:") != null); +}