From 6c3b7fa55a9edf65eeb5976e5f99fa4dbff2bc81 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 01:53:16 +0200 Subject: [PATCH] Expose local socket protocol and mimctl basics --- src/main.zig | 53 +++++++++-- src/protocol.zig | 119 +++++++++++++++++++++++++ src/socket.zig | 225 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 src/protocol.zig create mode 100644 src/socket.zig diff --git a/src/main.zig b/src/main.zig index 8b8caf6..fc87234 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const protocol = @import("protocol.zig"); const session = @import("session.zig"); +const socket = @import("socket.zig"); pub const version = "0.1.0-dev"; @@ -9,9 +11,10 @@ const help_text = \\Usage: \\ mim [--help] \\ mim [--version] + \\ mim --mimctl \\ - \\This skeleton intentionally starts with only the canonical smoke path. - \\Future editor behavior is added through verified product slices. + \\This early build exposes the canonical smoke path and a minimal local + \\session-control client for trusted tools. \\ ; @@ -36,18 +39,44 @@ fn versionText() []const 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); _ = args.skip(); + const stdout = std.Io.File.stdout(); + const stderr = std.Io.File.stderr(); + 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| parseArgs(&.{ "mim", arg }) else parseArgs(&.{"mim"}); - const stdout = std.Io.File.stdout(); - const stderr = std.Io.File.stderr(); - switch (command) { .help => try stdout.writeStreamingAll(init.io, help_text), .version => try stdout.writeStreamingAll(init.io, versionText()), @@ -63,8 +92,22 @@ pub fn main(init: std.process.Init) !u8 { 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 { + _ = protocol; _ = session; + _ = socket; } test "regular: help text names the binary and smoke boundary" { diff --git a/src/protocol.zig b/src/protocol.zig new file mode 100644 index 0000000..ecbc2d9 --- /dev/null +++ b/src/protocol.zig @@ -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); +} diff --git a/src/socket.zig b/src/socket.zig new file mode 100644 index 0000000..b4aa35c --- /dev/null +++ b/src/socket.zig @@ -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)); +}