Files
mim/src/job.zig
T
2026-06-21 05:38:52 +02:00

248 lines
11 KiB
Zig

const std = @import("std");
const diagnostics = @import("diagnostics.zig");
// Explicit local build/test job execution for the shared panel model.
// req: repo/002, ui/002, testing/001, testing/002, testing/003, testing/004
test {
_ = runRowsAlloc;
}
pub const Error = error{
InvalidCwd,
InvalidCommand,
InvalidJobRow,
InvalidPath,
};
pub const Location = struct {
path: []const u8,
line: usize,
column: usize,
};
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);
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:spawned:{s}", .{ kind, command_preview }));
const result = std.process.run(allocator, io, .{
.argv = argv,
.cwd = .{ .path = cwd },
.stdout_limit = .limited(diagnostics.process_stdout_limit),
.stderr_limit = .limited(diagnostics.process_stderr_limit),
}) catch |err| {
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:status:spawn_error_{s}", .{ kind, @errorName(err) }));
try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, kind, diagnostics.process_stdout_limit, diagnostics.process_stderr_limit));
return rows.toOwnedSlice(allocator);
};
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
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 std.fmt.allocPrint(allocator, "{s}:output_empty", .{kind}));
try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, kind, diagnostics.process_stdout_limit, diagnostics.process_stderr_limit));
return rows.toOwnedSlice(allocator);
}
pub fn locationFromRow(row: []const u8) !Location {
const prefix_end = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidJobRow;
const body = row[prefix_end + 1 ..];
if (!std.mem.eql(u8, row[0..prefix_end], "stdout") and !std.mem.eql(u8, row[0..prefix_end], "stderr")) return Error.InvalidJobRow;
const first = std.mem.indexOfScalar(u8, body, ':') orelse return Error.InvalidJobRow;
const path = body[0..first];
try validatePath(path);
const rest = body[first + 1 ..];
const second = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidJobRow;
const line = std.fmt.parseUnsigned(usize, rest[0..second], 10) catch return Error.InvalidJobRow;
if (line == 0) return Error.InvalidJobRow;
const after_line = rest[second + 1 ..];
const maybe_column_token = if (std.mem.indexOfScalar(u8, after_line, ':')) |third| after_line[0..third] else after_line;
const column = std.fmt.parseUnsigned(usize, maybe_column_token, 10) catch 1;
return .{ .path = path, .line = line, .column = if (column == 0) 1 else column };
}
pub fn byteOffsetForLineColumn(content: []const u8, line: usize, column: usize) !usize {
if (line == 0 or column == 0) return Error.InvalidJobRow;
var current_line: usize = 1;
var line_start: usize = 0;
var i: usize = 0;
while (i <= content.len) : (i += 1) {
if (i == content.len or content[i] == '\n') {
if (current_line == line) {
const line_bytes = content[line_start..i];
if (column - 1 > line_bytes.len) return Error.InvalidJobRow;
return line_start + column - 1;
}
current_line += 1;
line_start = i + 1;
}
}
return Error.InvalidJobRow;
}
fn validateCwd(cwd: []const u8) !void {
if (cwd.len == 0 or !std.unicode.utf8ValidateSlice(cwd)) return Error.InvalidCwd;
for (cwd) |byte| {
if (byte == 0 or byte == '\n' or byte == '\r' or byte == '|') return Error.InvalidCwd;
}
}
fn validateArgv(argv: []const []const u8) !void {
if (argv.len == 0) return Error.InvalidCommand;
for (argv) |arg| {
if (arg.len == 0 or !std.unicode.utf8ValidateSlice(arg)) return Error.InvalidCommand;
for (arg) |byte| {
if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidCommand;
}
}
}
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;
for (path) |byte| {
if (byte <= 0x20 or byte == '|') return Error.InvalidPath;
}
}
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, "{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);
}
fn appendOutputRows(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), prefix: []const u8, output: []const u8) !void {
var lines = std.mem.splitScalar(u8, output, '\n');
while (lines.next()) |raw_line| {
const trimmed = std.mem.trim(u8, raw_line, "\r");
if (trimmed.len == 0) continue;
const sanitized = try sanitizeLineAlloc(allocator, trimmed);
defer allocator.free(sanitized);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:{s}", .{ prefix, sanitized }));
}
}
fn sanitizeLineAlloc(allocator: std.mem.Allocator, line: []const u8) ![]u8 {
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
var i: usize = 0;
while (i < line.len and out.items.len < 160) {
const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
const end = @min(line.len, i + len);
const slice = line[i..end];
if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == '|')) {
try out.append(allocator, '_');
} else {
try out.appendSlice(allocator, slice);
}
i = end;
}
if (out.items.len == 0) try out.append(allocator, '_');
return out.toOwnedSlice(allocator);
}
fn commandPreviewAlloc(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 {
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
for (argv, 0..) |arg, index| {
if (index != 0) try out.append(allocator, '_');
const sanitized = try sanitizeLineAlloc(allocator, arg);
defer allocator.free(sanitized);
try out.appendSlice(allocator, sanitized);
if (out.items.len >= 120) break;
}
return out.toOwnedSlice(allocator);
}
fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void {
for (rows) |row| allocator.free(row);
allocator.free(rows);
}
test "regular: job rows capture failing command output and status" {
const rows = try runRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{ "sh", "-c", "printf 'src/main.zig:2:5:error:bad\\n'; exit 1" });
defer freeOwnedRows(std.testing.allocator, rows);
try std.testing.expect(rows.len >= 3);
try std.testing.expectEqualStrings("job:status:exit_1", rows[1]);
try std.testing.expectEqualStrings("stdout:src/main.zig:2:5:error:bad", rows[2]);
const loc = try locationFromRow(rows[2]);
try std.testing.expectEqualStrings("src/main.zig", loc.path);
try std.testing.expectEqual(@as(usize, 2), loc.line);
try std.testing.expectEqual(@as(usize, 5), loc.column);
}
test "regular: job row location converts to byte offset" {
try std.testing.expectEqual(@as(usize, 6), try byteOffsetForLineColumn("one\ntwo needle\n", 2, 3));
}
test "adversarial: invalid commands rows and unparseable output are rejected" {
try std.testing.expectError(Error.InvalidCommand, runRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{}));
try std.testing.expectError(Error.InvalidCwd, runRowsAlloc(std.testing.allocator, std.testing.io, "bad\nwd", &.{"true"}));
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:"));
}