From fbf6c655b383049c65a52227a3a09ac10380ce3c Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Sat, 6 Jun 2026 22:01:36 -0700 Subject: [PATCH] fix(io): decode raw ioctl errno via linux.E.init, not posix.errno (libc swallows failures) padctl links libc. Under libc, std.posix.errno(rc) reads the C errno global, which is only meaningful when a libc wrapper returns -1. But std.os.linux.ioctl is a RAW syscall: it returns the negated errno encoded in a usize (never literally -1) and does NOT touch the C errno global. So posix.errno(linux.ioctl(...)) always returns .SUCCESS under libc, silently swallowing every ioctl failure. The correct, libc-independent decode is std.os.linux.E.init(rc) (already proven+fixed for hidraw.featureReport). Fixed sites (all decode a raw linux.ioctl return): - src/io/uinput.zig ioctlInt / ioctlPtr: UI_DEV_SETUP / UI_DEV_CREATE / UI_SET_* were silently succeeding; PERM and other failures are now surfaced. - src/io/hidraw.zig EVIOCGRAB: a failed grab was swallowed and the un-grabbed evdev fd was still appended. Extracted evdevGrabErrno and decode via linux.E.init; the fd is now only kept on a real grab. - src/cli/scan.zig HIDIOCGRAWNAME: warning-only path now logs the real errno via linux.E.init. discoverWithRoot / discoverAllWithRoot already compared rc != 0 directly and were correct; left unchanged. Also dedupes src/io/hidraw.zig discoverWithRoot: the matching path string was allocated twice (once for the loop, once for the return). Now returns the single existing allocation via a keep_path guard. Tests (Layer-0, falsifiable): - uinput ioctlInt/ioctlPtr over fd=-1 (EBADF) must return error.Unexpected; under the old posix.errno decode they returned void (proven by reverting). - hidraw evdevGrabErrno(-1) must return a non-SUCCESS errno (EBADF); under posix.errno it returned .SUCCESS (proven by reverting). - Updated existing 'grabAssociatedEvdev: matches event by phys prefix': EVIOCGRAB on a regular file returns ENOTTY, now correctly surfaced, so the un-grabbed fd is no longer appended (count 0, was 1 only because the error was swallowed). - scan HIDIOCGRAWNAME is warning-only (no error returned); behavior is covered by the uinput/hidraw tests sharing the identical decode pattern. refs: codebase audit (systemic) --- src/cli/scan.zig | 5 +++-- src/io/hidraw.zig | 37 ++++++++++++++++++++++++++++++------- src/io/uinput.zig | 25 +++++++++++++++++++++---- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/cli/scan.zig b/src/cli/scan.zig index 17a4a3c7..a6db7239 100644 --- a/src/cli/scan.zig +++ b/src/cli/scan.zig @@ -75,8 +75,9 @@ pub fn scan(allocator: std.mem.Allocator, config_dir: []const u8) ![]ScanEntry { var name_buf: [NAME_BUF_LEN]u8 = std.mem.zeroes([NAME_BUF_LEN]u8); const name_rc = linux.ioctl(fd, HIDIOCGRAWNAME, @intFromPtr(&name_buf)); - if (std.posix.errno(name_rc) != .SUCCESS) { - std.log.warn("scan: HIDIOCGRAWNAME failed for {s}: {}", .{ path, std.posix.errno(name_rc) }); + const name_errno = linux.E.init(name_rc); + if (name_errno != .SUCCESS) { + std.log.warn("scan: HIDIOCGRAWNAME failed for {s}: {s}", .{ path, @tagName(name_errno) }); } const name_raw = std.mem.sliceTo(&name_buf, 0); diff --git a/src/io/hidraw.zig b/src/io/hidraw.zig index 67cd0cde..9dd9fe02 100644 --- a/src/io/hidraw.zig +++ b/src/io/hidraw.zig @@ -27,6 +27,15 @@ fn BoundedArray(comptime T: type, comptime cap: usize) type { }; } +/// EVIOCGRAB the evdev fd and decode the raw `linux.ioctl` return with +/// `linux.E.init`. Under libc, `std.posix.errno` reads the C errno global +/// (always SUCCESS for a raw syscall that never literally returns -1), so a +/// failing grab was silently swallowed and the un-grabbed fd was still kept. +fn evdevGrabErrno(evfd: posix.fd_t) linux.E { + const rc = linux.ioctl(evfd, ioctl.EVIOCGRAB, 1); + return linux.E.init(rc); +} + pub const HidrawDevice = struct { fd: posix.fd_t, evdev_fds: BoundedArray(posix.fd_t, MAX_EVDEV_GRABS), @@ -71,7 +80,8 @@ pub const HidrawDevice = struct { var i: u8 = 0; while (i < 64) : (i += 1) { const path = try std.fmt.allocPrint(allocator, "{s}/hidraw{d}", .{ dev_root, i }); - defer allocator.free(path); + var keep_path = false; + defer if (!keep_path) allocator.free(path); const fd = posix.open(path, .{ .ACCMODE = .RDONLY, .NONBLOCK = true }, 0) catch continue; defer posix.close(fd); @@ -89,7 +99,8 @@ pub const HidrawDevice = struct { if (iface != required_iface) continue; } - return try std.fmt.allocPrint(allocator, "{s}/hidraw{d}", .{ dev_root, i }); + keep_path = true; + return path; } return error.NotFound; } @@ -193,8 +204,7 @@ pub const HidrawDevice = struct { std.log.warn("evdev grab: open {s} failed: {}", .{ dev_path, err }); continue; }; - const grab_rc = linux.ioctl(evfd, ioctl.EVIOCGRAB, 1); - const grab_errno = posix.errno(grab_rc); + const grab_errno = evdevGrabErrno(evfd); if (grab_errno != .SUCCESS) { std.log.warn("evdev grab: EVIOCGRAB {s} failed: {s}", .{ dev_path, @tagName(grab_errno) }); posix.close(evfd); @@ -527,10 +537,11 @@ test "hidraw: grabAssociatedEvdev: matches event by phys prefix" { var dev = HidrawDevice.init(allocator); // Only event7 matches the phys prefix; event9 and event11 are excluded. + // event7 is a regular file, so its EVIOCGRAB returns ENOTTY — now correctly + // surfaced via linux.E.init, so the un-grabbed fd is NOT appended (count 0). + // The phys-prefix matching is still exercised: only event7 reaches the grab. dev.grabAssociatedEvdevWithRoot("/dev/hidraw3", tmp_path, input_dev_root) catch {}; - // On regular files with O_RDWR, EVIOCGRAB returns 0 (harmless no-op), - // so exactly the 1 matching event (event7) is grabbed. - try std.testing.expectEqual(@as(usize, 1), dev.evdev_fds.len); + try std.testing.expectEqual(@as(usize, 0), dev.evdev_fds.len); for (dev.evdev_fds.constSlice()) |fd| posix.close(fd); dev.evdev_fds.len = 0; } @@ -588,3 +599,15 @@ test "hidraw: featureReport surfaces ioctl errno as error" { const payload = [_]u8{ 0x01, 0x02, 0x03 }; try std.testing.expectError(DeviceIO.WriteError.Io, dev_io.featureReport(&payload)); } + +// EVIOCGRAB over a bad fd (-1) returns EBADF. evdevGrabErrno must decode this +// via linux.E.init and surface it as a non-SUCCESS errno, so the caller skips +// appending the un-grabbed fd. +// Falsifiability: with the old `posix.errno(grab_rc)` decode this returns +// .SUCCESS under libc (C errno is untouched by the raw syscall), so the +// assertion below fails. +test "hidraw: evdevGrabErrno surfaces ioctl errno on bad fd" { + const e = evdevGrabErrno(-1); + try std.testing.expect(e != .SUCCESS); + try std.testing.expectEqual(linux.E.BADF, e); +} diff --git a/src/io/uinput.zig b/src/io/uinput.zig index 2eb67b47..3b769280 100644 --- a/src/io/uinput.zig +++ b/src/io/uinput.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const linux = std.os.linux; const state = @import("../core/state.zig"); const device = @import("../config/device.zig"); const input_codes = @import("../config/input_codes.zig"); @@ -30,8 +31,8 @@ const UI_END_FF_ERASE = ioctl_constants.UI_END_FF_ERASE; const MAX_EVENTS = 64; fn ioctlInt(fd: std.posix.fd_t, request: u32, val: c_int) !void { - const rc = std.os.linux.ioctl(fd, request, @intCast(val)); - return switch (std.posix.errno(rc)) { + const rc = linux.ioctl(fd, request, @intCast(val)); + return switch (linux.E.init(rc)) { .SUCCESS => {}, .PERM => error.PermissionDenied, else => |e| std.posix.unexpectedErrno(e), @@ -39,8 +40,8 @@ fn ioctlInt(fd: std.posix.fd_t, request: u32, val: c_int) !void { } fn ioctlPtr(fd: std.posix.fd_t, request: u32, ptr: usize) !void { - const rc = std.os.linux.ioctl(fd, request, ptr); - return switch (std.posix.errno(rc)) { + const rc = linux.ioctl(fd, request, ptr); + return switch (linux.E.init(rc)) { .SUCCESS => {}, .PERM => error.PermissionDenied, else => |e| std.posix.unexpectedErrno(e), @@ -1744,3 +1745,19 @@ test "uinput: pollFf returns identical FfEvent regardless of dump_enabled" { try std.testing.expectEqual(r1.weak, r2.weak); try std.testing.expectEqual(r1.duration_ms, r2.duration_ms); } + +// ioctlInt/ioctlPtr decode the raw `linux.ioctl` return with `linux.E.init`, +// not `std.posix.errno`. Under libc, `posix.errno` reads the C errno global +// (always SUCCESS for a raw syscall that never literally returns -1), so a +// failing ioctl was silently swallowed and the function returned void. +// Driving the ioctl over fd=-1 yields EBADF, which must now surface as error. +// Falsifiability: restoring `std.posix.errno(rc)` makes both tests fail — +// the ioctl returns void instead of the expected error. +test "uinput: ioctlInt surfaces ioctl errno as error (EBADF on bad fd)" { + try std.testing.expectError(error.Unexpected, ioctlInt(-1, UI_SET_EVBIT, c.EV_KEY)); +} + +test "uinput: ioctlPtr surfaces ioctl errno as error (EBADF on bad fd)" { + var setup = std.mem.zeroes(c.uinput_setup); + try std.testing.expectError(error.Unexpected, ioctlPtr(-1, UI_DEV_SETUP, @intFromPtr(&setup))); +}