repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

commit
fb0e621
parent
f520c4c
author
Michael Sakaluk
date
2026-03-10 18:09:01 -0400 EDT
fix: validate session name length against Unix socket path limit

Session names that cause the socket path to exceed the OS sun_path limit
(104 bytes on macOS, 108 on Linux) now produce a clear error message
instead of failing silently with exit code 1.

- Add max_socket_path_len constant derived from platform sockaddr_un
- Add validation in getSocketPath returning error.NameTooLong
- Add maxSessionNameLen helper for dynamic limit computation
- Add printSessionNameTooLong for user-facing error on stderr
- Handle NameTooLong in all command paths (run, attach, kill, history, detach, list)
- Move validation before sessionExists in kill/history so the error
  message is shown even when the socket file doesn't exist
- Add 6 unit tests covering boundary conditions and platform constants

Fixes: https://github.com/neurosnap/zmx/issues/84
3 files changed,  +135, -12
M src/main.zig
+41, -9
  1@@ -128,7 +128,10 @@ pub fn main() !void {
  2             .cwd = cwd,
  3             .created_at = @intCast(std.time.timestamp()),
  4         };
  5-        daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
  6+        daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
  7+            error.NameTooLong => return printSessionNameTooLong(sesh, &cfg),
  8+            error.OutOfMemory => return err,
  9+        };
 10         std.log.info("socket path={s}", .{daemon.socket_path});
 11         return attach(&daemon);
 12     } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 13@@ -160,7 +163,10 @@ pub fn main() !void {
 14             .is_task_mode = true,
 15             .task_command = cmd_args_raw.items,
 16         };
 17-        daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
 18+        daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
 19+            error.NameTooLong => return printSessionNameTooLong(sesh, &cfg),
 20+            error.OutOfMemory => return err,
 21+        };
 22         std.log.info("socket path={s}", .{daemon.socket_path});
 23         return run(&daemon, cmd_args_raw.items);
 24     } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
 25@@ -711,6 +717,23 @@ fn help() !void {
 26     try w.interface.flush();
 27 }
 28 
 29+fn printSessionNameTooLong(session_name: []const u8, cfg: *Cfg) void {
 30+    var buf: [4096]u8 = undefined;
 31+    var w = std.fs.File.stderr().writer(&buf);
 32+    if (socket.maxSessionNameLen(cfg.socket_dir)) |max_len| {
 33+        w.interface.print(
 34+            "error: session name is too long ({d} bytes, max {d} for socket directory \"{s}\")\n",
 35+            .{ session_name.len, max_len, cfg.socket_dir },
 36+        ) catch {};
 37+    } else {
 38+        w.interface.print(
 39+            "error: socket directory path is too long (\"{s}\")\n",
 40+            .{cfg.socket_dir},
 41+        ) catch {};
 42+    }
 43+    w.interface.flush() catch {};
 44+}
 45+
 46 fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
 47     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 48     defer _ = gpa.deinit();
 49@@ -860,7 +883,10 @@ fn detachAll(cfg: *Cfg) !void {
 50     var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 51     defer dir.close();
 52 
 53-    const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
 54+    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
 55+        error.NameTooLong => return printSessionNameTooLong(session_name, cfg),
 56+        error.OutOfMemory => return err,
 57+    };
 58     defer alloc.free(socket_path);
 59     const result = ipc.probeSession(alloc, socket_path) catch |err| {
 60         std.log.err("session unresponsive: {s}", .{@errorName(err)});
 61@@ -879,6 +905,12 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
 62     defer _ = gpa.deinit();
 63     const alloc = gpa.allocator();
 64 
 65+    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
 66+        error.NameTooLong => return printSessionNameTooLong(session_name, cfg),
 67+        error.OutOfMemory => return err,
 68+    };
 69+    defer alloc.free(socket_path);
 70+
 71     var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 72     defer dir.close();
 73 
 74@@ -890,9 +922,6 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
 75         w.interface.flush() catch {};
 76         return error.SessionNotFound;
 77     }
 78-
 79-    const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
 80-    defer alloc.free(socket_path);
 81     const result = ipc.probeSession(alloc, socket_path) catch |err| {
 82         std.log.err("session unresponsive: {s}", .{@errorName(err)});
 83         var buf: [4096]u8 = undefined;
 84@@ -923,6 +952,12 @@ fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !voi
 85     defer _ = gpa.deinit();
 86     const alloc = gpa.allocator();
 87 
 88+    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
 89+        error.NameTooLong => return printSessionNameTooLong(session_name, cfg),
 90+        error.OutOfMemory => return err,
 91+    };
 92+    defer alloc.free(socket_path);
 93+
 94     var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 95     defer dir.close();
 96 
 97@@ -934,9 +969,6 @@ fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !voi
 98         w.interface.flush() catch {};
 99         return error.SessionNotFound;
100     }
101-
102-    const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
103-    defer alloc.free(socket_path);
104     const result = ipc.probeSession(alloc, socket_path) catch |err| {
105         std.log.err("session unresponsive: {s}", .{@errorName(err)});
106         if (err == error.ConnectionRefused) socket.cleanupStaleSocket(dir, session_name);
M src/socket.zig
+90, -2
  1@@ -67,11 +67,99 @@ pub fn createSocket(fname: []const u8) !i32 {
  2     return fd;
  3 }
  4 
  5-pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
  6+/// Maximum number of usable bytes in a Unix domain socket path.
  7+/// Derived from the platform's sockaddr_un.path field, minus 1 for the
  8+/// required null terminator.
  9+pub const max_socket_path_len: usize = @typeInfo(
 10+    @TypeOf(@as(posix.sockaddr.un, undefined).path),
 11+).array.len - 1;
 12+
 13+pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) error{ NameTooLong, OutOfMemory }![]const u8 {
 14     const dir = socket_dir;
 15-    const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
 16+    const path_len = dir.len + 1 + session_name.len;
 17+    if (path_len > max_socket_path_len) return error.NameTooLong;
 18+    const fname = try alloc.alloc(u8, path_len);
 19     @memcpy(fname[0..dir.len], dir);
 20     @memcpy(fname[dir.len .. dir.len + 1], "/");
 21     @memcpy(fname[dir.len + 1 ..], session_name);
 22     return fname;
 23 }
 24+
 25+/// Returns the maximum session name length for a given socket directory,
 26+/// or null if the socket directory itself is already too long.
 27+pub fn maxSessionNameLen(socket_dir: []const u8) ?usize {
 28+    // path = socket_dir + "/" + session_name
 29+    const overhead = socket_dir.len + 1;
 30+    if (overhead >= max_socket_path_len) return null;
 31+    return max_socket_path_len - overhead;
 32+}
 33+
 34+test "max_socket_path_len matches platform sockaddr_un" {
 35+    const path_field_len = @typeInfo(
 36+        @TypeOf(@as(posix.sockaddr.un, undefined).path),
 37+    ).array.len;
 38+    try std.testing.expectEqual(path_field_len - 1, max_socket_path_len);
 39+    try std.testing.expect(max_socket_path_len > 0);
 40+}
 41+
 42+test "getSocketPath succeeds for paths within limit" {
 43+    const alloc = std.testing.allocator;
 44+    const result = try getSocketPath(alloc, "/tmp/zmx", "mysession");
 45+    defer alloc.free(result);
 46+    try std.testing.expectEqualStrings("/tmp/zmx/mysession", result);
 47+}
 48+
 49+test "getSocketPath returns NameTooLong when path exceeds limit" {
 50+    const alloc = std.testing.allocator;
 51+    const dir = [_]u8{'d'} ** (max_socket_path_len - 2);
 52+    const dir_slice: []const u8 = &dir;
 53+
 54+    const ok = try getSocketPath(alloc, dir_slice, "x");
 55+    defer alloc.free(ok);
 56+    try std.testing.expectEqual(max_socket_path_len, ok.len);
 57+
 58+    const err = getSocketPath(alloc, dir_slice, "xx");
 59+    try std.testing.expectError(error.NameTooLong, err);
 60+}
 61+
 62+test "getSocketPath returns NameTooLong for empty dir with oversized name" {
 63+    const alloc = std.testing.allocator;
 64+    const name = [_]u8{'n'} ** (max_socket_path_len);
 65+    const name_slice: []const u8 = &name;
 66+    const err = getSocketPath(alloc, "", name_slice);
 67+    try std.testing.expectError(error.NameTooLong, err);
 68+}
 69+
 70+test "maxSessionNameLen computes correct dynamic limit" {
 71+    const short_dir = "/tmp/zmx";
 72+    const short_max = maxSessionNameLen(short_dir).?;
 73+    try std.testing.expectEqual(max_socket_path_len - short_dir.len - 1, short_max);
 74+
 75+    const full_dir = [_]u8{'f'} ** max_socket_path_len;
 76+    const full_dir_slice: []const u8 = &full_dir;
 77+    try std.testing.expectEqual(@as(?usize, null), maxSessionNameLen(full_dir_slice));
 78+
 79+    const tight_dir = [_]u8{'t'} ** (max_socket_path_len - 2);
 80+    const tight_dir_slice: []const u8 = &tight_dir;
 81+    try std.testing.expectEqual(@as(?usize, 1), maxSessionNameLen(tight_dir_slice));
 82+}
 83+
 84+test "getSocketPath boundary: name fills exactly to limit" {
 85+    const alloc = std.testing.allocator;
 86+    const dir = "/tmp/zmx";
 87+    const max_name_len = maxSessionNameLen(dir).?;
 88+
 89+    const name_at_limit = try alloc.alloc(u8, max_name_len);
 90+    defer alloc.free(name_at_limit);
 91+    @memset(name_at_limit, 'a');
 92+
 93+    const path = try getSocketPath(alloc, dir, name_at_limit);
 94+    defer alloc.free(path);
 95+    try std.testing.expectEqual(max_socket_path_len, path.len);
 96+
 97+    const name_over_limit = try alloc.alloc(u8, max_name_len + 1);
 98+    defer alloc.free(name_over_limit);
 99+    @memset(name_over_limit, 'b');
100+
101+    try std.testing.expectError(error.NameTooLong, getSocketPath(alloc, dir, name_over_limit));
102+}
M src/util.zig
+4, -1
 1@@ -40,7 +40,10 @@ pub fn get_session_entries(alloc: std.mem.Allocator, socket_dir: []const u8) !st
 2             const name = try alloc.dupe(u8, entry.name);
 3             errdefer alloc.free(name);
 4 
 5-            const socket_path = try socket.getSocketPath(alloc, socket_dir, entry.name);
 6+            const socket_path = socket.getSocketPath(alloc, socket_dir, entry.name) catch |err| switch (err) {
 7+                error.NameTooLong => continue,
 8+                error.OutOfMemory => return err,
 9+            };
10             defer alloc.free(socket_path);
11 
12             const result = ipc.probeSession(alloc, socket_path) catch |err| {