// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The primary and the hand-off to it, on Windows: what `Activation.zig` //! does over the session bus, over a named pipe. //! //! The primary owns `\\.\pipe\dev.jcollie.harrier-`, named for the //! user and open only to them. Every later launch hands off to it: it //! connects, sends its request — the command, where and with which //! environment, a serial port — and exits, and the primary opens the //! window. With no one listening, the launch keeps its request and becomes //! the primary itself. //! //! A pipe's name is anyone's to take, so a launch checks before it sends //! anything that the process at the other end is the same user's: a request //! carries the user's environment. //! //! An elevated primary has a pipe of its own, `…--admin`, so that an //! elevated launch never hands off to a primary that is not elevated, nor //! the other way: its windows would run with the wrong rights (see //! `docs/adr/0001-one-primary-per-elevation.md`). A request to it runs as //! administrator, so it takes one only from an elevated process, by the //! pipe's integrity label and by asking; and an elevated launch hands off //! only to an elevated primary. //! //! The server runs on a thread of its own, as the session bus's does. It //! only reads requests into a queue and wakes the main loop, which opens //! the windows. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const win32 = @import("win32").everything; const Wake = @import("os/Wake.zig"); const req = @import("request.zig"); const elevation = @import("windows/elevation.zig"); const search = @import("shell_search.zig"); const log = std.log.scoped(.instance); pub const app_id = req.app_id; pub const Launcher = req.Launcher; pub const Request = req.Request; /// A request bigger than this is refused: an environment is a few /// kilobytes. const max_request = 1024 * 1024; const L = std.unicode.utf8ToUtf16LeStringLiteral; // --------------------------------------------------------------------------- // The hand-off to the primary, which owns the pipe // --------------------------------------------------------------------------- /// Hands `request` off to the primary. True if it took it; false if there /// is none to ask, and the caller should become the primary and open the /// window itself. pub fn handOff(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, request: Request) bool { _ = io; _ = env; var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const me = userSid(arena) catch return false; const admin = elevation.elevated(); const name = pipeName(arena, me.string, admin) catch return false; const pipe = win32.CreateFileW(name, .{ .FILE_READ_DATA = 1, .FILE_WRITE_DATA = 1 }, .{}, null, win32.OPEN_EXISTING, .{}, null); if (pipe == win32.INVALID_HANDLE_VALUE) return false; defer _ = win32.CloseHandle(pipe); // Whoever is at the other end must be this user, and as elevated as // this launch. var server_pid: u32 = 0; if (win32.GetNamedPipeServerProcessId(pipe, &server_pid) == 0) return false; const theirs = processSid(arena, server_pid) catch return false; if (win32.EqualSid(@ptrCast(me.sid), @ptrCast(theirs)) == 0) { log.warn("not handing the window off: the pipe belongs to another user", .{}); return false; } if (admin and !elevation.pidElevated(server_pid)) { log.warn("not handing the window off: the administrator's pipe belongs to a harrier that is not elevated", .{}); return false; } var mode: win32.NAMED_PIPE_MODE = win32.PIPE_READMODE_MESSAGE; _ = win32.SetNamedPipeHandleState(pipe, &mode, null, null); const message = encode(arena, request) catch return false; // The primary may bring its new window forward, which this launch, the // one the user just started, is allowed to let it do. _ = win32.AllowSetForegroundWindow(server_pid); var written: u32 = 0; if (win32.WriteFile(pipe, message.ptr, @intCast(message.len), &written, null) == 0 or written != message.len) return false; // One byte back says the request was taken. var ack: [1]u8 = undefined; var read: u32 = 0; return win32.ReadFile(pipe, &ack, 1, &read, null) != 0 and read == 1 and ack[0] == 1; } // --------------------------------------------------------------------------- // Owning the pipe // --------------------------------------------------------------------------- pub const Server = struct { gpa: Allocator, /// Wakes the main loop when a request arrives. Wait on `wake.handle`. wake: Wake, /// The server thread has stopped, for good. gone: std.atomic.Value(bool) = .init(false), stopping: std.atomic.Value(bool) = .init(false), name: [:0]const u16, security: ?*anyopaque, /// The pipe the thread is listening on. pipe: win32.HANDLE, /// This harrier is elevated: it takes requests only from processes /// that are. admin: bool, thread: std.Thread = undefined, lock: win32.RTL_SRWLOCK = .{ .Ptr = null }, pending: std.ArrayList(Request) = .empty, pub const StartError = error{ /// Another harrier has the pipe. Taken, SystemResources, OutOfMemory, }; pub fn start(gpa: Allocator, io: Io, env: *const std.process.Environ.Map) StartError!*Server { _ = io; _ = env; const s = try gpa.create(Server); errdefer gpa.destroy(s); var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const me = userSid(arena) catch return error.SystemResources; const admin = elevation.elevated(); const name = try gpa.dupeSentinel(u16, pipeName(arena, me.string, admin) catch return error.OutOfMemory, 0); errdefer gpa.free(name); // Only this user, and nothing from another machine; for an // elevated harrier, nothing below high integrity may write to it, // which a connection must. const label = if (admin) "S:(ML;;NW;;;HI)" else ""; const sddl = std.unicode.utf8ToUtf16LeAllocZ(arena, std.fmt.allocPrint(arena, "D:P(A;;GA;;;{s}){s}", .{ me.string, label }) catch return error.OutOfMemory) catch return error.OutOfMemory; var security: ?*anyopaque = null; if (win32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, win32.SDDL_REVISION_1, @ptrCast(&security), null) == 0) return error.SystemResources; errdefer _ = win32.LocalFree(@bitCast(@intFromPtr(security))); s.* = .{ .gpa = gpa, .wake = Wake.init() catch return error.SystemResources, .name = name, .security = security, .pipe = undefined, .admin = admin, }; errdefer s.wake.deinit(); s.pipe = try s.listen(); errdefer _ = win32.CloseHandle(s.pipe); s.thread = std.Thread.spawn(.{}, run, .{s}) catch return error.SystemResources; log.info("serving {s} on a named pipe", .{app_id}); return s; } /// The pipe, to wait for launches on, one at a time: the first instance /// there is, or another harrier has it. fn listen(s: *Server) StartError!win32.HANDLE { var attributes: win32.SECURITY_ATTRIBUTES = .{ .nLength = @sizeOf(win32.SECURITY_ATTRIBUTES), .lpSecurityDescriptor = s.security, .bInheritHandle = win32.FALSE, }; // zigwin32 names these bits for what they mean on a file, so they // are put together by their values. const duplex: u32 = @bitCast(win32.PIPE_ACCESS_DUPLEX); const first_instance: u32 = @bitCast(win32.FILE_FLAG_FIRST_PIPE_INSTANCE); const open_mode: win32.FILE_FLAGS_AND_ATTRIBUTES = @bitCast(duplex | first_instance); var pipe_mode: win32.NAMED_PIPE_MODE = win32.PIPE_TYPE_MESSAGE; pipe_mode.READMODE_MESSAGE = 1; pipe_mode.REJECT_REMOTE_CLIENTS = 1; const pipe = win32.CreateNamedPipeW(s.name, open_mode, pipe_mode, win32.PIPE_UNLIMITED_INSTANCES, 4096, 64 * 1024, 0, &attributes); if (pipe == win32.INVALID_HANDLE_VALUE) return switch (win32.GetLastError()) { .ERROR_ACCESS_DENIED => error.Taken, else => error.SystemResources, }; return pipe; } /// Takes a launch at a time, until stopped. fn run(s: *Server) void { defer s.gone.store(true, .release); var buf: std.ArrayList(u8) = .empty; defer buf.deinit(s.gpa); while (!s.stopping.load(.acquire)) { const connected = win32.ConnectNamedPipe(s.pipe, null) != 0 or win32.GetLastError() == .ERROR_PIPE_CONNECTED; if (s.stopping.load(.acquire)) break; if (connected) s.serve(&buf); _ = win32.DisconnectNamedPipe(s.pipe); } _ = win32.CloseHandle(s.pipe); } /// Reads one request from the launch connected, queues it, and says so. fn serve(s: *Server, buf: *std.ArrayList(u8)) void { buf.clearRetainingCapacity(); if (s.admin) { var client: u32 = 0; if (win32.GetNamedPipeClientProcessId(s.pipe, &client) == 0 or !elevation.pidElevated(client)) { log.warn("a request on the administrator's pipe from a process that is not elevated, refused", .{}); return; } } var chunk: [16 * 1024]u8 = undefined; while (true) { var n: u32 = 0; const ok = win32.ReadFile(s.pipe, &chunk, chunk.len, &n, null) != 0; buf.appendSlice(s.gpa, chunk[0..n]) catch return; if (buf.items.len > max_request) return; if (ok) break; if (win32.GetLastError() != .ERROR_MORE_DATA) return; } const request = decode(s.gpa, buf.items) catch |err| { log.warn("a request on the pipe: {t}", .{err}); return; }; { win32.AcquireSRWLockExclusive(&s.lock); defer win32.ReleaseSRWLockExclusive(&s.lock); s.pending.append(s.gpa, request) catch { var r = request; r.deinit(s.gpa); return; }; } s.wake.signal(); var written: u32 = 0; _ = win32.WriteFile(s.pipe, &[_]u8{1}, 1, &written, null); _ = win32.FlushFileBuffers(s.pipe); } pub fn destroy(s: *Server) void { s.stopping.store(true, .release); // A connection of its own gets the thread out of waiting for one. const poke = win32.CreateFileW(s.name, .{ .FILE_READ_DATA = 1, .FILE_WRITE_DATA = 1 }, .{}, null, win32.OPEN_EXISTING, .{}, null); if (poke != win32.INVALID_HANDLE_VALUE) _ = win32.CloseHandle(poke); s.thread.join(); for (s.pending.items) |*r| r.deinit(s.gpa); s.pending.deinit(s.gpa); _ = win32.LocalFree(@bitCast(@intFromPtr(s.security))); s.gpa.free(s.name); s.wake.deinit(); s.gpa.destroy(s); } /// The next window asked for, owned by the caller. pub fn take(s: *Server) ?Request { win32.AcquireSRWLockExclusive(&s.lock); defer win32.ReleaseSRWLockExclusive(&s.lock); if (s.pending.items.len == 0) return null; return s.pending.orderedRemove(0); } /// The taskbar's progress is the platform's, on each window's button. pub fn publishLauncher(s: *Server, l: Launcher) void { _ = s; _ = l; } /// Windows has no shell search to publish panes to. pub fn publishPanes(s: *Server, panes: []const search.Pane) Allocator.Error!void { _ = s; _ = panes; } /// Nor profiles. pub fn publishProfiles(s: *Server, profiles: []const search.Profile) Allocator.Error!void { _ = s; _ = profiles; } }; // --------------------------------------------------------------------------- // Who is asking // --------------------------------------------------------------------------- const Sid = struct { sid: *anyopaque, /// `S-1-5-21-…`. string: []const u8, }; /// This process's user. fn userSid(arena: Allocator) !Sid { const sid = try tokenSid(arena, win32.GetCurrentProcess()); var wide: ?[*:0]u16 = null; if (win32.ConvertSidToStringSidW(@ptrCast(sid), &wide) == 0) return error.Unexpected; defer _ = win32.LocalFree(@bitCast(@intFromPtr(wide))); return .{ .sid = sid, .string = try std.unicode.utf16LeToUtf8Alloc(arena, std.mem.sliceTo(wide.?, 0)) }; } /// The user process `pid` runs as. fn processSid(arena: Allocator, pid: u32) !*anyopaque { const process = win32.OpenProcess(win32.PROCESS_QUERY_LIMITED_INFORMATION, win32.FALSE, pid) orelse return error.AccessDenied; defer _ = win32.CloseHandle(process); return tokenSid(arena, process); } fn tokenSid(arena: Allocator, process: ?win32.HANDLE) !*anyopaque { var token: ?win32.HANDLE = null; if (win32.OpenProcessToken(process, win32.TOKEN_QUERY, &token) == 0) return error.AccessDenied; defer _ = win32.CloseHandle(token); var size: u32 = 0; _ = win32.GetTokenInformation(token, win32.TokenUser, null, 0, &size); if (size == 0) return error.Unexpected; const buf = try arena.alignedAlloc(u8, .of(win32.TOKEN_USER), size); if (win32.GetTokenInformation(token, win32.TokenUser, buf.ptr, size, &size) == 0) return error.Unexpected; const user: *const win32.TOKEN_USER = @ptrCast(buf.ptr); return user.User.Sid orelse error.Unexpected; } fn pipeName(arena: Allocator, sid: []const u8, admin: bool) ![:0]const u16 { const suffix = if (admin) "-admin" else ""; return std.unicode.utf8ToUtf16LeAllocZ(arena, try std.fmt.allocPrint(arena, "\\\\.\\pipe\\{s}-{s}{s}", .{ app_id, sid, suffix })); } // --------------------------------------------------------------------------- // The request, as it goes down the pipe // --------------------------------------------------------------------------- /// What a request starts with, and its version. const magic = "harrier-request-1\n"; /// `request` as bytes: a line saying what it is, then each field a byte /// saying which, and its value — a string as its length and its bytes, a /// list of strings as their count and each, a number as itself — all little /// endian. fn encode(arena: Allocator, request: Request) Allocator.Error![]u8 { var out: std.Io.Writer.Allocating = .init(arena); const w = &out.writer; encodeTo(w, request) catch return error.OutOfMemory; return out.written(); } const Field = enum(u8) { command = 1, cwd, environ, serial_path, baud, new_tab, profile, focus_pane, focus_primary }; fn encodeTo(w: *std.Io.Writer, r: Request) std.Io.Writer.Error!void { try w.writeAll(magic); if (r.command) |c| { try w.writeByte(@backingInt(Field.command)); try writeList(w, c); } if (r.cwd) |c| { try w.writeByte(@backingInt(Field.cwd)); try writeString(w, c); } if (r.environ) |e| { try w.writeByte(@backingInt(Field.environ)); try writeList(w, e); } if (r.serial_path) |p| { try w.writeByte(@backingInt(Field.serial_path)); try writeString(w, p); } if (r.baud) |b| { try w.writeByte(@backingInt(Field.baud)); try w.writeInt(u32, b, .little); } if (r.new_tab) try w.writeByte(@backingInt(Field.new_tab)); if (r.profile) |e| { try w.writeByte(@backingInt(Field.profile)); try writeString(w, e); } if (r.focus_pane) |id| { try w.writeByte(@backingInt(Field.focus_pane)); try w.writeInt(u64, id, .little); } if (r.focus_primary) |pid| { try w.writeByte(@backingInt(Field.focus_primary)); try w.writeInt(u32, pid, .little); } } fn writeString(w: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void { try w.writeInt(u32, @intCast(s.len), .little); try w.writeAll(s); } fn writeList(w: *std.Io.Writer, list: []const []const u8) std.Io.Writer.Error!void { try w.writeInt(u32, @intCast(list.len), .little); for (list) |s| try writeString(w, s); } const DecodeError = error{ Malformed, OutOfMemory }; /// The request `bytes` hold, every field owned. fn decode(gpa: Allocator, bytes: []const u8) DecodeError!Request { if (!std.mem.startsWith(u8, bytes, magic)) return error.Malformed; var r: std.Io.Reader = .fixed(bytes[magic.len..]); var request: Request = .{}; errdefer request.deinit(gpa); while (r.takeByte()) |tag| { const field = std.enums.fromInt(Field, tag) orelse return error.Malformed; switch (field) { .command => request.command = try readList(gpa, &r), .cwd => request.cwd = try readString(gpa, &r), .environ => request.environ = try readList(gpa, &r), .serial_path => request.serial_path = try readString(gpa, &r), .baud => request.baud = r.takeInt(u32, .little) catch return error.Malformed, .new_tab => request.new_tab = true, .profile => request.profile = try readString(gpa, &r), .focus_pane => request.focus_pane = r.takeInt(u64, .little) catch return error.Malformed, .focus_primary => request.focus_primary = r.takeInt(u32, .little) catch return error.Malformed, } } else |_| {} return request; } fn readString(gpa: Allocator, r: *std.Io.Reader) DecodeError![]const u8 { const len = r.takeInt(u32, .little) catch return error.Malformed; const bytes = r.take(len) catch return error.Malformed; return gpa.dupe(u8, bytes); } fn readList(gpa: Allocator, r: *std.Io.Reader) DecodeError![]const []const u8 { const n = r.takeInt(u32, .little) catch return error.Malformed; if (n > max_request / 4) return error.Malformed; const list = try gpa.alloc([]const u8, n); var done: usize = 0; errdefer { for (list[0..done]) |s| gpa.free(s); gpa.free(list); } for (list) |*s| { s.* = try readString(gpa, r); done += 1; } return list; } test "a request goes down the pipe and comes back the same" { const testing = std.testing; var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const sent: Request = .{ .command = &.{ "pwsh", "-NoLogo" }, .cwd = "C:\\Users\\me", .environ = &.{ "PATH=C:\\Windows", "TERM=xterm-256color" }, .serial_path = "COM3", .baud = 115200, .new_tab = true, .profile = "admin shell", .focus_pane = 7, .focus_primary = 4242, }; const bytes = try encode(arena_state.allocator(), sent); var got = try decode(testing.allocator, bytes); defer got.deinit(testing.allocator); try testing.expectEqual(@as(usize, 2), got.command.?.len); try testing.expectEqualStrings("-NoLogo", got.command.?[1]); try testing.expectEqualStrings("C:\\Users\\me", got.cwd.?); try testing.expectEqualStrings("TERM=xterm-256color", got.environ.?[1]); try testing.expectEqualStrings("COM3", got.serial_path.?); try testing.expectEqual(@as(?u32, 115200), got.baud); try testing.expect(got.new_tab); try testing.expectEqualStrings("admin shell", got.profile.?); try testing.expectEqual(@as(?u64, 7), got.focus_pane); try testing.expectEqual(@as(?u32, 4242), got.focus_primary); var empty = try decode(testing.allocator, try encode(arena_state.allocator(), .{})); defer empty.deinit(testing.allocator); try testing.expect(empty.command == null and !empty.new_tab); try testing.expectError(error.Malformed, decode(testing.allocator, "nonsense")); try testing.expectError(error.Malformed, decode(testing.allocator, magic ++ "\x01\xff\xff\xff\xff")); } test "a launch hands off to the primary serving the pipe" { const testing = std.testing; var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); // A primary of the user's own has the pipe. const server = Server.start(testing.allocator, testing.io, &env) catch |err| switch (err) { error.Taken => return error.SkipZigTest, else => return err, }; defer server.destroy(); try testing.expect(handOff(testing.allocator, testing.io, &env, .{ .command = &.{"cmd.exe"}, .new_tab = true })); try testing.expectEqual(win32.WAIT_OBJECT_0, win32.WaitForSingleObject(server.wake.handle, 5000)); var got = server.take().?; defer got.deinit(testing.allocator); try testing.expectEqualStrings("cmd.exe", got.command.?[0]); try testing.expect(got.new_tab); try testing.expect(server.take() == null); // A second server finds the pipe taken. try testing.expectError(error.Taken, Server.start(testing.allocator, testing.io, &env)); }