╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │1 -//! haxy-side dispatcher for SSH sessions served by serve_ssh_protocol. │ │2 -//! routes shell + pty-req → StreamTerminal (TUI), exec git-upload-pack /│ │3 -//! git-receive-pack → repo.uploadPack / repo.receivePack (git). │ │4 - │ │1 const std = @import("std"); │ │2 const xit = @import("xit"); │ │3 const rp = xit.repo; │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────╮ │ 8 const ui = @import("./ui.zig"); │ │ 9 const ssh = @import("./serve_ssh_protocol.zig"); │ │10 const evt = @import("./event.zig"); │ │11 +const serve_common = @import("./serve_common.zig");│ │12 │ │13 pub const SessionHandler = struct { │ │14 admin_repo_path: []const u8, │ ╰───────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │37 } │ │38 } │ │39 }; │ │40 + │ │41 +pub fn runListener( │ │42 + io: std.Io, │ │43 + allocator: std.mem.Allocator, │ │44 + host_key: *const ssh.HostKey, │ │45 + session_handler: *const SessionHandler, │ │46 + net_server: *std.Io.net.Server, │ │47 + tasks: *std.Io.Group, │ │48 + err: *std.Io.Writer, │ │49 +) void { │ │50 + const Context = struct { │ │51 + io: std.Io, │ │52 + allocator: std.mem.Allocator, │ │53 + host_key: *const ssh.HostKey, │ │54 + session_handler: *const SessionHandler, │ │55 + err: *std.Io.Writer, │ │56 + }; │ │57 + │ │58 + const handle = struct { │ │59 + fn h(ctx: Context, stream: std.Io.net.Stream) void { │ │60 + defer stream.close(ctx.io); │ │61 + var recv_buf: [4096]u8 = undefined; │ │62 + var send_buf: [4096]u8 = undefined; │ │63 + var stream_reader = stream.reader(ctx.io, &recv_buf); │ │64 + var stream_writer = stream.writer(ctx.io, &send_buf); │ │65 + ssh.handleConnection( │ │66 + ctx.io, │ │67 + ctx.allocator, │ │68 + &stream_reader.interface, │ │69 + &stream_writer.interface, │ │70 + ctx.host_key, │ │71 + ctx.session_handler, │ │72 + ) catch |session_err| { │ │73 + serve_common.logError(ctx.err, "ssh session failed: {s}\n", .{@errorName(session_err)});│ │74 + }; │ │75 + } │ │76 + }.h; │ │77 + │ │78 + serve_common.runListener(io, net_server, tasks, err, "ssh", Context{ │ │79 + .io = io, │ │80 + .allocator = allocator, │ │81 + .host_key = host_key, │ │82 + .session_handler = session_handler, │ │83 + .err = err, │ │84 + }, handle); │ │85 +} │ │86 │ │87 fn runTuiSession(handler: *const SessionHandler, sess: *ssh.SessionCtx, pty: ssh.PtySize) !void { │ │88 // runTui owns the terminal, whose deinit restores the client's screen and │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │208 const allocator = sess.allocator; │ │209 const io = sess.io; │ │210 │ │168 - const parsed = parseGitCommand(allocator, command) catch { │ │169 - try writeError(sess, "unsupported command (expected git-upload-pack or git-receive-pack)"); │ │170 - try sess.exit(1); │ │171 - return; │ │172 - }; │ │211 + const parsed = parseGitCommand(allocator, command) catch return writeError(sess, "unsupported command (expected git-upload-pack or git-receive-pack)");│ │212 defer parsed.deinit(allocator); │ │213 │ │214 const create_if_missing = parsed.service == .receive_pack; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │215 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; │ │216 │ │178 - // derive the on-disk repo path. normally resolve <owner>/<repo> through the │ │179 - // event store (minting a repo event for a fresh push) and serve from its │ │180 - // event-id directory; in test mode serve the path directly │ │181 - const repo_path = if (handler.is_test) blk: { │ │182 - const direct_path = try resolveRepoPath(allocator, handler.repo_root_path, parsed.dir); │ │183 - if (!isSubPath(handler.repo_root_path, direct_path)) { │ │184 - allocator.free(direct_path); │ │185 - try writeError(sess, "forbidden path"); │ │186 - try sess.exit(1); │ │187 - return; │ │188 - } │ │189 - break :blk direct_path; │ │190 - } else blk: { │ │191 - const owner_repo = evt.parseOwnerRepoPath(parsed.dir) orelse { │ │192 - try writeError(sess, "repo path must be <owner>/<repo>"); │ │193 - try sess.exit(1); │ │194 - return; │ │195 - }; │ │196 - const event_id_hex = (try evt.resolveOrCreateRepo(io, allocator, handler.admin_repo_path, owner_repo.owner, owner_repo.name, create_if_missing)) orelse { │ │197 - try writeError(sess, "repo not found"); │ │198 - try sess.exit(1); │ │199 - return; │ │200 - }; │ │201 - break :blk try std.fs.path.join(allocator, &.{ handler.repo_root_path, &event_id_hex }); │ │217 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, handler.is_test, handler.repo_root_path, handler.admin_repo_path, parsed.dir, create_if_missing)) {│ │218 + .ok => |p| p, │ │219 + .invalid => return writeError(sess, "repo path must be <owner>/<repo>"), │ │220 + .not_found => return writeError(sess, "repo not found"), │ │221 }; │ │222 defer allocator.free(repo_path); │ │223 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │226 return; │ │227 } │ │228 │ │210 - if (!create_if_missing) { │ │211 - try writeError(sess, "repo not found"); │ │212 - try sess.exit(1); │ │213 - return; │ │214 - } │ │229 + if (!create_if_missing) return writeError(sess, "repo not found"); │ │230 │ │231 // create the on-disk repo for the just-minted event and serve the push │ │232 var repo = try createRepo(any_repo_opts.toRepoOpts(), io, allocator, repo_path);│ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │314 return error.UnsupportedService; │ │315 │ │316 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; │ │302 -} │ │303 - │ │304 -fn resolveRepoPath(allocator: std.mem.Allocator, repo_root_path: []const u8, dir: []const u8) ![]const u8 {│ │305 - if (std.fs.path.isAbsolute(dir)) return try std.fs.path.resolve(allocator, &.{dir}); │ │306 - return try std.fs.path.resolve(allocator, &.{ repo_root_path, dir }); │ │307 -} │ │308 - │ │309 -fn isSubPath(parent: []const u8, child: []const u8) bool { │ │310 - if (std.mem.eql(u8, parent, std.fs.path.sep_str)) return std.fs.path.isAbsolute(child); │ │311 - if (!std.mem.startsWith(u8, child, parent)) return false; │ │312 - return child.len == parent.len or child[parent.len] == std.fs.path.sep; │ │317 } │ │318 │ │319 +// write an error to the client and exit non-zero │ │320 fn writeError(sess: *ssh.SessionCtx, msg: []const u8) !void { │ │321 var buf: [256]u8 = undefined; │ │322 const text = try std.fmt.bufPrint(&buf, "haxy ssh: {s}\n", .{msg}); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────╮ │323 try sess.writeBytes(text); │ │324 + try sess.exit(1); │ │325 } │ │326 │ │327 test "parseGitCommand rejects malformed input" {│ ╰─────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │351 defer parsed.deinit(allocator); │ │352 try std.testing.expectEqual(GitService.upload_pack, parsed.service); │ │353 try std.testing.expectEqualStrings("repo", parsed.dir); │ │348 - } │ │349 -} │ │350 - │ │351 -test "isSubPath rejects prefix collisions and traversal" { │ │352 - const sep = std.fs.path.sep_str; │ │353 - const parent = sep ++ "srv" ++ sep ++ "git"; │ │354 - │ │355 - try std.testing.expect(isSubPath(parent, parent ++ sep ++ "repo")); │ │356 - try std.testing.expect(isSubPath(parent, parent)); │ │357 - │ │358 - // common-prefix-but-not-subpath: "/srv/git" should NOT swallow │ │359 - // "/srv/git2" or "/srv/gitignore" just because the bytes start the same. │ │360 - try std.testing.expect(!isSubPath(parent, parent ++ "2")); │ │361 - try std.testing.expect(!isSubPath(parent, parent ++ "ignore")); │ │362 - │ │363 - // resolveRepoPath normalizes `..` first, so `/srv/git/../etc/passwd` │ │364 - // becomes `/etc/passwd` — isSubPath then rejects it. │ │365 - try std.testing.expect(!isSubPath(parent, sep ++ "etc" ++ sep ++ "passwd"));│ │366 - │ │367 - const builtin = @import("builtin"); │ │368 - │ │369 - if (.windows != builtin.os.tag) { │ │370 - try std.testing.expect(isSubPath(sep, sep ++ "anything")); │ │371 - try std.testing.expect(isSubPath(sep, sep)); │ │354 } │ │355 } │ │356 │ ╰─────────────────────────────────────────────────────────────────────────────────────╯