Expose local socket protocol and mimctl basics

This commit is contained in:
slhx agent
2026-06-21 01:53:16 +02:00
parent 108c0533a8
commit 6c3b7fa55a
3 changed files with 392 additions and 5 deletions
+48 -5
View File
@@ -1,5 +1,7 @@
const std = @import("std"); const std = @import("std");
const protocol = @import("protocol.zig");
const session = @import("session.zig"); const session = @import("session.zig");
const socket = @import("socket.zig");
pub const version = "0.1.0-dev"; pub const version = "0.1.0-dev";
@@ -9,9 +11,10 @@ const help_text =
\\Usage: \\Usage:
\\ mim [--help] \\ mim [--help]
\\ mim [--version] \\ mim [--version]
\\ mim --mimctl <socket> <request...>
\\ \\
\\This skeleton intentionally starts with only the canonical smoke path. \\This early build exposes the canonical smoke path and a minimal local
\\Future editor behavior is added through verified product slices. \\session-control client for trusted tools.
\\ \\
; ;
@@ -36,18 +39,44 @@ fn versionText() []const u8 {
} }
pub fn main(init: std.process.Init) !u8 { pub fn main(init: std.process.Init) !u8 {
var debug_allocator = std.heap.DebugAllocator(.{}){};
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
var args = std.process.Args.Iterator.init(init.minimal.args); var args = std.process.Args.Iterator.init(init.minimal.args);
_ = args.skip(); _ = args.skip();
const stdout = std.Io.File.stdout();
const stderr = std.Io.File.stderr();
const first_arg = args.next(); const first_arg = args.next();
if (first_arg) |arg| {
if (std.mem.eql(u8, arg, "--mimctl")) {
const socket_path = args.next() orelse {
try stderr.writeStreamingAll(init.io, "mim: --mimctl requires a socket path\n");
return 64;
};
const line = try collectRemainingArgs(allocator, &args);
defer allocator.free(line);
if (line.len == 0) {
try stderr.writeStreamingAll(init.io, "mim: --mimctl requires a request\n");
return 64;
}
const response = socket.request(allocator, socket_path, line) catch {
try stderr.writeStreamingAll(init.io, "mim: mimctl request failed\n");
return 69;
};
defer allocator.free(response);
try stdout.writeStreamingAll(init.io, response);
return 0;
}
}
const command = if (first_arg) |arg| const command = if (first_arg) |arg|
parseArgs(&.{ "mim", arg }) parseArgs(&.{ "mim", arg })
else else
parseArgs(&.{"mim"}); parseArgs(&.{"mim"});
const stdout = std.Io.File.stdout();
const stderr = std.Io.File.stderr();
switch (command) { switch (command) {
.help => try stdout.writeStreamingAll(init.io, help_text), .help => try stdout.writeStreamingAll(init.io, help_text),
.version => try stdout.writeStreamingAll(init.io, versionText()), .version => try stdout.writeStreamingAll(init.io, versionText()),
@@ -63,8 +92,22 @@ pub fn main(init: std.process.Init) !u8 {
return 0; return 0;
} }
fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) ![]u8 {
var line = std.ArrayList(u8).empty;
errdefer line.deinit(allocator);
var first = true;
while (args.next()) |arg| {
if (!first) try line.append(allocator, ' ');
try line.appendSlice(allocator, arg);
first = false;
}
return line.toOwnedSlice(allocator);
}
test { test {
_ = protocol;
_ = session; _ = session;
_ = socket;
} }
test "regular: help text names the binary and smoke boundary" { test "regular: help text names the binary and smoke boundary" {
+119
View File
@@ -0,0 +1,119 @@
const std = @import("std");
const session_mod = @import("session.zig");
// Tiny local session protocol for trusted same-user tools.
// req: session/002, session/003, governance/003
test {
_ = handleLine;
}
pub const max_request_bytes = 4096;
pub fn handleLine(allocator: std.mem.Allocator, session: *session_mod.Session, line: []const u8) ![]u8 {
const trimmed = std.mem.trimEnd(u8, line, "\r\n");
if (std.mem.eql(u8, trimmed, "state")) return stateResponse(allocator, session);
if (std.mem.startsWith(u8, trimmed, "open ")) {
const bytes = trimmed[5..];
if (!std.unicode.utf8ValidateSlice(bytes)) return allocator.dupe(u8, "err invalid utf8\n");
try session.openFixture(bytes);
return stateResponse(allocator, session);
}
if (std.mem.startsWith(u8, trimmed, "command ")) {
return commandResponse(allocator, session, trimmed[8..]);
}
return allocator.dupe(u8, "err unknown request\n");
}
fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, command: []const u8) ![]u8 {
if (std.mem.eql(u8, command, "move_left")) return dispatchAndRespond(allocator, session, .move_left);
if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right);
if (std.mem.eql(u8, command, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward);
if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] });
return allocator.dupe(u8, "err unknown command\n");
}
fn dispatchAndRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: session_mod.Command) ![]u8 {
session.dispatch(command) catch |err| switch (err) {
error.InvalidUtf8Insertion => return allocator.dupe(u8, "err invalid utf8\n"),
error.NoBufferOpen => return allocator.dupe(u8, "err no buffer open\n"),
else => return err,
};
return stateResponse(allocator, session);
}
fn stateResponse(allocator: std.mem.Allocator, session: *session_mod.Session) ![]u8 {
const snap = session.snapshot() catch |err| switch (err) {
error.NoBufferOpen => return allocator.dupe(u8, "err no buffer open\n"),
};
return std.fmt.allocPrint(
allocator,
"ok state cursor_byte={d} cursor_cell={d} bytes_len={d}\n",
.{ snap.cursor_byte, snap.cursor_cell, snap.bytes.len },
);
}
test "regular: protocol opens bytes, reports state, and dispatches commands" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const open = try handleLine(std.testing.allocator, &session, "open café\n");
defer std.testing.allocator.free(open);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=5\n", open);
const moved = try handleLine(std.testing.allocator, &session, "command move_right\n");
defer std.testing.allocator.free(moved);
try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=5\n", moved);
const inserted = try handleLine(std.testing.allocator, &session, "command insert 🔥\n");
defer std.testing.allocator.free(inserted);
try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=3 bytes_len=9\n", inserted);
}
test "regular: protocol delete command removes a whole UTF-8 codepoint" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
var response = try handleLine(std.testing.allocator, &session, "open aé\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command delete_backward\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=1\n", response);
}
test "adversarial: protocol rejects unknown requests and commands" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const request = try handleLine(std.testing.allocator, &session, "subscribe everything\n");
defer std.testing.allocator.free(request);
try std.testing.expectEqualStrings("err unknown request\n", request);
try session.openFixture("abc");
const command = try handleLine(std.testing.allocator, &session, "command plugin.load\n");
defer std.testing.allocator.free(command);
try std.testing.expectEqualStrings("err unknown command\n", command);
}
test "adversarial: protocol reports no buffer and invalid utf8 without corrupting state" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const no_buffer = try handleLine(std.testing.allocator, &session, "state\n");
defer std.testing.allocator.free(no_buffer);
try std.testing.expectEqualStrings("err no buffer open\n", no_buffer);
try session.openFixture("safe");
const before = try session.snapshot();
const bad = [_]u8{ 'c', 'o', 'm', 'm', 'a', 'n', 'd', ' ', 'i', 'n', 's', 'e', 'r', 't', ' ', 0xc3, 0x28 };
const invalid = try handleLine(std.testing.allocator, &session, &bad);
defer std.testing.allocator.free(invalid);
try std.testing.expectEqualStrings("err invalid utf8\n", invalid);
const after = try session.snapshot();
try std.testing.expectEqualStrings(before.bytes, after.bytes);
}
+225
View File
@@ -0,0 +1,225 @@
const std = @import("std");
const protocol = @import("protocol.zig");
const session_mod = @import("session.zig");
// Local Unix socket transport for a running editor session.
// req: session/002, session/003, governance/003
test {
_ = Server;
}
pub const socket_env_name = "MIM_SOCKET";
const SocketError = error{
PathTooLong,
SocketCreateFailed,
BindFailed,
ListenFailed,
AcceptFailed,
ConnectFailed,
WriteFailed,
RequestTooLarge,
};
pub const Server = struct {
allocator: std.mem.Allocator,
path: []const u8,
fd: std.posix.socket_t,
session: *session_mod.Session,
pub fn listen(allocator: std.mem.Allocator, path: []const u8, session: *session_mod.Session) !Server {
unlinkPath(path);
const fd = try createSocket();
errdefer closeFd(fd);
const addr = try unixAddress(path);
if (std.posix.errno(std.posix.system.bind(fd, @ptrCast(&addr.un), addr.len)) != .SUCCESS) return SocketError.BindFailed;
if (std.posix.errno(std.posix.system.listen(fd, 8)) != .SUCCESS) return SocketError.ListenFailed;
return .{
.allocator = allocator,
.path = try allocator.dupe(u8, path),
.fd = fd,
.session = session,
};
}
pub fn deinit(self: *Server) void {
closeFd(self.fd);
unlinkPath(self.path);
self.allocator.free(self.path);
self.* = undefined;
}
pub fn acceptOnce(self: *Server) !void {
var storage: std.posix.sockaddr.storage = undefined;
var len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr.storage);
const accepted_rc = std.posix.system.accept(self.fd, @ptrCast(&storage), &len);
if (std.posix.errno(accepted_rc) != .SUCCESS) return SocketError.AcceptFailed;
const accepted: std.posix.fd_t = @intCast(accepted_rc);
defer closeFd(accepted);
var request_buf: [protocol.max_request_bytes + 1]u8 = undefined;
const line = try readRequest(accepted, &request_buf);
const response = protocol.handleLine(self.allocator, self.session, line) catch |err| switch (err) {
error.OutOfMemory => return err,
else => try self.allocator.dupe(u8, "err internal\n"),
};
defer self.allocator.free(response);
try writeAll(accepted, response);
}
};
pub fn request(allocator: std.mem.Allocator, path: []const u8, line: []const u8) ![]u8 {
const fd = try createSocket();
defer closeFd(fd);
const addr = try unixAddress(path);
if (std.posix.errno(std.posix.system.connect(fd, @ptrCast(&addr.un), addr.len)) != .SUCCESS) return SocketError.ConnectFailed;
try writeAll(fd, line);
if (!std.mem.endsWith(u8, line, "\n")) try writeAll(fd, "\n");
var response = std.ArrayList(u8).empty;
errdefer response.deinit(allocator);
var buf: [512]u8 = undefined;
while (true) {
const n = try readSome(fd, &buf);
if (n == 0) break;
try response.appendSlice(allocator, buf[0..n]);
if (std.mem.indexOfScalar(u8, buf[0..n], '\n') != null) break;
}
return response.toOwnedSlice(allocator);
}
fn createSocket() !std.posix.socket_t {
const rc = std.posix.system.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, 0);
if (std.posix.errno(rc) != .SUCCESS) return SocketError.SocketCreateFailed;
return @intCast(rc);
}
const UnixAddress = struct {
un: std.posix.sockaddr.un,
len: std.posix.socklen_t,
};
fn unixAddress(path: []const u8) !UnixAddress {
var addr: std.posix.sockaddr.un = .{
.family = std.posix.AF.UNIX,
.path = [_]u8{0} ** 108,
};
if (path.len >= addr.path.len) return SocketError.PathTooLong;
@memcpy(addr.path[0..path.len], path);
return .{
.un = addr,
.len = @intCast(@offsetOf(std.posix.sockaddr.un, "path") + path.len + 1),
};
}
fn readRequest(fd: std.posix.fd_t, buf: []u8) ![]const u8 {
var len: usize = 0;
while (len < buf.len) {
const n = try readSome(fd, buf[len..]);
if (n == 0) break;
len += n;
if (std.mem.indexOfScalar(u8, buf[0..len], '\n')) |newline| return buf[0 .. newline + 1];
}
if (len >= buf.len) return SocketError.RequestTooLarge;
return buf[0..len];
}
fn readSome(fd: std.posix.fd_t, buf: []u8) !usize {
while (true) {
const rc = std.posix.system.read(fd, buf.ptr, buf.len);
switch (std.posix.errno(rc)) {
.SUCCESS => return @intCast(rc),
.INTR => continue,
else => return SocketError.ConnectFailed,
}
}
}
fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
var offset: usize = 0;
while (offset < bytes.len) {
const rc = std.posix.system.write(fd, bytes[offset..].ptr, bytes.len - offset);
switch (std.posix.errno(rc)) {
.SUCCESS => offset += @intCast(rc),
.INTR => continue,
else => return SocketError.WriteFailed,
}
}
}
fn closeFd(fd: std.posix.fd_t) void {
_ = std.posix.system.close(fd);
}
fn unlinkPath(path: []const u8) void {
const posix_path = std.posix.toPosixPath(path) catch return;
_ = std.posix.system.unlink(&posix_path);
}
test "regular: local socket state request observes a running session" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
try session.openFixture("abc");
const path = "/tmp/mim-test-state.sock";
var server = try Server.listen(std.testing.allocator, path, &session);
defer server.deinit();
const thread = try std.Thread.spawn(.{}, Server.acceptOnce, .{&server});
const response = try request(std.testing.allocator, path, "state");
defer std.testing.allocator.free(response);
thread.join();
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=3\n", response);
}
test "regular: local socket command mutates session state" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
try session.openFixture("abc");
const path = "/tmp/mim-test-command.sock";
var server = try Server.listen(std.testing.allocator, path, &session);
defer server.deinit();
var thread = try std.Thread.spawn(.{}, Server.acceptOnce, .{&server});
var response = try request(std.testing.allocator, path, "command move_right");
std.testing.allocator.free(response);
thread.join();
thread = try std.Thread.spawn(.{}, Server.acceptOnce, .{&server});
response = try request(std.testing.allocator, path, "command insert é");
defer std.testing.allocator.free(response);
thread.join();
try std.testing.expectEqualStrings("ok state cursor_byte=3 cursor_cell=2 bytes_len=5\n", response);
}
test "adversarial: socket transport returns explicit protocol errors" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const path = "/tmp/mim-test-error.sock";
var server = try Server.listen(std.testing.allocator, path, &session);
defer server.deinit();
const thread = try std.Thread.spawn(.{}, Server.acceptOnce, .{&server});
const response = try request(std.testing.allocator, path, "command move_right");
defer std.testing.allocator.free(response);
thread.join();
try std.testing.expectEqualStrings("err no buffer open\n", response);
}
test "adversarial: socket path rejects overlong Unix socket paths" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const long_path = "/tmp/this-mim-socket-path-is-intentionally-far-too-long-for-a-portable-unix-domain-socket-address-and-must-fail.sock";
try std.testing.expectError(SocketError.PathTooLong, Server.listen(std.testing.allocator, long_path, &session));
}