╭ changed ────╮ │src/serve.zig│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │322 │ │323 fn run(conn: @This()) void { │ │324 defer conn.stream.close(conn.io); │ │325 - serve_ssh_protocol.handleConnection(conn.io, conn.allocator, conn.stream, conn.host_key, conn.session_handler) catch |session_err| {│ │325 + var recv_buf: [4096]u8 = undefined; │ │326 + var send_buf: [4096]u8 = undefined; │ │327 + var stream_reader = conn.stream.reader(conn.io, &recv_buf); │ │328 + var stream_writer = conn.stream.writer(conn.io, &send_buf); │ │329 + serve_ssh_protocol.handleConnection( │ │330 + conn.io, │ │331 + conn.allocator, │ │332 + &stream_reader.interface, │ │333 + &stream_writer.interface, │ │334 + conn.host_key, │ │335 + conn.session_handler, │ │336 + ) catch |session_err| { │ │337 logError(conn.err, "ssh session failed: {s}\n", .{@errorName(session_err)}); │ │338 }; │ │339 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │211 }; │ │212 } │ │213 │ │214 -pub fn parseGitCommand(allocator: std.mem.Allocator, command: []const u8) !ParsedGitCommand { │ │214 +fn parseGitCommand(allocator: std.mem.Allocator, command: []const u8) !ParsedGitCommand { │ │215 var tokens = try std.process.Args.IteratorGeneral(.{ .single_quotes = true }).init(allocator, command);│ │216 defer tokens.deinit(); │ │217 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ │234 return try std.fs.path.resolve(allocator, &.{ repo_root_path, dir }); │ │235 } │ │236 │ │237 -pub fn isSubPath(parent: []const u8, child: []const u8) bool { │ │237 +fn isSubPath(parent: []const u8, child: []const u8) bool { │ │238 if (std.mem.eql(u8, parent, std.fs.path.sep_str)) return std.fs.path.isAbsolute(child);│ │239 if (!std.mem.startsWith(u8, child, parent)) return false; │ │240 return child.len == parent.len or child[parent.len] == std.fs.path.sep; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │244 var buf: [256]u8 = undefined; │ │245 const text = try std.fmt.bufPrint(&buf, "haxy ssh: {s}\n", .{msg}); │ │246 try sess.writeBytes(text); │ │247 +} │ │248 + │ │249 +test "parseGitCommand rejects malformed input" { │ │250 + const allocator = std.testing.allocator; │ │251 + try std.testing.expectError(error.InvalidCommand, parseGitCommand(allocator, "git-upload-pack")); │ │252 + try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "ls -la")); │ │253 + try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "git-fake-pack 'repo'"));│ │254 +} │ │255 + │ │256 +test "parseGitCommand happy paths" { │ │257 + const allocator = std.testing.allocator; │ │258 + │ │259 + { │ │260 + const parsed = try parseGitCommand(allocator, "git-upload-pack 'some-repo'"); │ │261 + defer parsed.deinit(allocator); │ │262 + try std.testing.expectEqual(GitService.upload_pack, parsed.service); │ │263 + try std.testing.expectEqualStrings("some-repo", parsed.dir); │ │264 + } │ │265 + { │ │266 + const parsed = try parseGitCommand(allocator, "git-receive-pack 'user/proj'"); │ │267 + defer parsed.deinit(allocator); │ │268 + try std.testing.expectEqual(GitService.receive_pack, parsed.service); │ │269 + try std.testing.expectEqualStrings("user/proj", parsed.dir); │ │270 + } │ │271 + { │ │272 + const parsed = try parseGitCommand(allocator, "git-upload-pack repo"); │ │273 + defer parsed.deinit(allocator); │ │274 + try std.testing.expectEqual(GitService.upload_pack, parsed.service); │ │275 + try std.testing.expectEqualStrings("repo", parsed.dir); │ │276 + } │ │277 } │ │278 │ │279 +test "isSubPath rejects prefix collisions and traversal" { │ │280 + const sep = std.fs.path.sep_str; │ │281 + const parent = sep ++ "srv" ++ sep ++ "git"; │ │282 + │ │283 + try std.testing.expect(isSubPath(parent, parent ++ sep ++ "repo")); │ │284 + try std.testing.expect(isSubPath(parent, parent)); │ │285 + │ │286 + // common-prefix-but-not-subpath: "/srv/git" should NOT swallow │ │287 + // "/srv/git2" or "/srv/gitignore" just because the bytes start the same. │ │288 + try std.testing.expect(!isSubPath(parent, parent ++ "2")); │ │289 + try std.testing.expect(!isSubPath(parent, parent ++ "ignore")); │ │290 + │ │291 + // resolveRepoPath normalizes `..` first, so `/srv/git/../etc/passwd` │ │292 + // becomes `/etc/passwd` — isSubPath then rejects it. │ │293 + try std.testing.expect(!isSubPath(parent, sep ++ "etc" ++ sep ++ "passwd")); │ │294 + │ │295 + const builtin = @import("builtin"); │ │296 + │ │297 + if (.windows != builtin.os.tag) { │ │298 + try std.testing.expect(isSubPath(sep, sep ++ "anything")); │ │299 + try std.testing.expect(isSubPath(sep, sep)); │ │300 + } │ │301 +} │ │302 + │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │53 const max_exit_drain_packets: u32 = 32; │ │54 │ │55 // SSH message type bytes (RFC 4250 §4.1) │ │56 -const SSH_MSG_DISCONNECT: u8 = 1; │ │57 -const SSH_MSG_SERVICE_REQUEST: u8 = 5; │ │58 -const SSH_MSG_SERVICE_ACCEPT: u8 = 6; │ │59 -const SSH_MSG_KEXINIT: u8 = 20; │ │60 -const SSH_MSG_NEWKEYS: u8 = 21; │ │61 -const SSH_MSG_KEX_ECDH_INIT: u8 = 30; │ │62 -const SSH_MSG_KEX_ECDH_REPLY: u8 = 31; │ │63 -const SSH_MSG_USERAUTH_REQUEST: u8 = 50; │ │64 -const SSH_MSG_USERAUTH_FAILURE: u8 = 51; │ │65 -const SSH_MSG_USERAUTH_SUCCESS: u8 = 52; │ │66 -const SSH_MSG_USERAUTH_PK_OK: u8 = 60; // method-specific name for publickey │ │67 -const SSH_MSG_GLOBAL_REQUEST: u8 = 80; │ │68 -const SSH_MSG_REQUEST_SUCCESS: u8 = 81; │ │69 -const SSH_MSG_REQUEST_FAILURE: u8 = 82; │ │70 -const SSH_MSG_CHANNEL_OPEN: u8 = 90; │ │71 -const SSH_MSG_CHANNEL_OPEN_CONFIRMATION: u8 = 91; │ │72 -const SSH_MSG_CHANNEL_OPEN_FAILURE: u8 = 92; │ │73 -const SSH_MSG_CHANNEL_WINDOW_ADJUST: u8 = 93; │ │74 -const SSH_MSG_CHANNEL_DATA: u8 = 94; │ │75 -const SSH_MSG_CHANNEL_EXTENDED_DATA: u8 = 95; │ │76 -const SSH_MSG_CHANNEL_EOF: u8 = 96; │ │77 -const SSH_MSG_CHANNEL_CLOSE: u8 = 97; │ │78 -const SSH_MSG_CHANNEL_REQUEST: u8 = 98; │ │79 -const SSH_MSG_CHANNEL_SUCCESS: u8 = 99; │ │80 -const SSH_MSG_CHANNEL_FAILURE: u8 = 100; │ │56 +pub const SSH_MSG_DISCONNECT: u8 = 1; │ │57 +pub const SSH_MSG_SERVICE_REQUEST: u8 = 5; │ │58 +pub const SSH_MSG_SERVICE_ACCEPT: u8 = 6; │ │59 +pub const SSH_MSG_KEXINIT: u8 = 20; │ │60 +pub const SSH_MSG_NEWKEYS: u8 = 21; │ │61 +pub const SSH_MSG_KEX_ECDH_INIT: u8 = 30; │ │62 +pub const SSH_MSG_KEX_ECDH_REPLY: u8 = 31; │ │63 +pub const SSH_MSG_USERAUTH_REQUEST: u8 = 50; │ │64 +pub const SSH_MSG_USERAUTH_FAILURE: u8 = 51; │ │65 +pub const SSH_MSG_USERAUTH_SUCCESS: u8 = 52; │ │66 +pub const SSH_MSG_USERAUTH_PK_OK: u8 = 60; // method-specific name for publickey│ │67 +pub const SSH_MSG_GLOBAL_REQUEST: u8 = 80; │ │68 +pub const SSH_MSG_REQUEST_SUCCESS: u8 = 81; │ │69 +pub const SSH_MSG_REQUEST_FAILURE: u8 = 82; │ │70 +pub const SSH_MSG_CHANNEL_OPEN: u8 = 90; │ │71 +pub const SSH_MSG_CHANNEL_OPEN_CONFIRMATION: u8 = 91; │ │72 +pub const SSH_MSG_CHANNEL_OPEN_FAILURE: u8 = 92; │ │73 +pub const SSH_MSG_CHANNEL_WINDOW_ADJUST: u8 = 93; │ │74 +pub const SSH_MSG_CHANNEL_DATA: u8 = 94; │ │75 +pub const SSH_MSG_CHANNEL_EXTENDED_DATA: u8 = 95; │ │76 +pub const SSH_MSG_CHANNEL_EOF: u8 = 96; │ │77 +pub const SSH_MSG_CHANNEL_CLOSE: u8 = 97; │ │78 +pub const SSH_MSG_CHANNEL_REQUEST: u8 = 98; │ │79 +pub const SSH_MSG_CHANNEL_SUCCESS: u8 = 99; │ │80 +pub const SSH_MSG_CHANNEL_FAILURE: u8 = 100; │ │81 │ │82 -const SSH_DISCONNECT_PROTOCOL_ERROR: u32 = 2; │ │82 +pub const SSH_DISCONNECT_PROTOCOL_ERROR: u32 = 2; │ │83 │ │84 // SSH_OPEN_* reason codes for CHANNEL_OPEN_FAILURE │ │85 -const SSH_OPEN_RESOURCE_SHORTAGE: u32 = 4; │ │86 -const SSH_OPEN_UNKNOWN_CHANNEL_TYPE: u32 = 3; │ │85 +pub const SSH_OPEN_RESOURCE_SHORTAGE: u32 = 4; │ │86 +pub const SSH_OPEN_UNKNOWN_CHANNEL_TYPE: u32 = 3; │ │87 │ │88 // data type code for CHANNEL_EXTENDED_DATA (stderr) │ │89 -const SSH_EXTENDED_DATA_STDERR: u32 = 1; │ │89 +pub const SSH_EXTENDED_DATA_STDERR: u32 = 1; │ │90 │ │91 // initial flow-control window we advertise. typical openssh setting; large │ │92 // enough that small interactive sessions never need a WINDOW_ADJUST. │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │133 │ │134 /// SSH wire-format ed25519 public key blob (used as K_S in the hash and │ │135 /// returned in KEX_ECDH_REPLY): string "ssh-ed25519" || string pubkey. │ │136 - fn appendPublicBlob(self: HostKey, buf: *std.ArrayList(u8), allocator: std.mem.Allocator) !void { │ │136 + pub fn appendPublicBlob(self: HostKey, buf: *std.ArrayList(u8), allocator: std.mem.Allocator) !void {│ │137 try writeStringField(buf, allocator, "ssh-ed25519"); │ │138 try writeStringField(buf, allocator, &self.keypair.public_key.bytes); │ │139 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │541 /// `handler` must be a pointer to a struct with a method: │ │542 /// pub fn handleSession(self, sess: *SessionCtx, request: Request) anyerror!void│ │543 /// invoked once the channel is open and a shell/exec request has arrived. │ │544 +/// │ │545 +/// `reader` and `writer` are the bidirectional byte stream │ │546 pub fn handleConnection( │ │547 io: std.Io, │ │548 allocator: std.mem.Allocator, │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────╮ │547 - stream: std.Io.net.Stream,│ │549 + reader: *std.Io.Reader, │ │550 + writer: *std.Io.Writer, │ │551 host_key: *const HostKey, │ │552 handler: anytype, │ │553 ) !void { │ ╰───────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │551 - var recv_buf: [4096]u8 = undefined; │ │552 - var send_buf: [4096]u8 = undefined; │ │553 - var stream_reader = stream.reader(io, &recv_buf); │ │554 - var stream_writer = stream.writer(io, &send_buf); │ │555 - const reader = &stream_reader.interface; │ │556 - const writer = &stream_writer.interface; │ │557 - │ │554 const client_version = try exchangeVersions(allocator, reader, writer);│ │555 defer allocator.free(client_version); │ │556 │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │594 // binary packet codec, unencrypted form (RFC 4253 §6) │ │595 // --------------------------------------------------------------------------- │ │596 │ │601 -fn writePlainPacket(io: std.Io, writer: *std.Io.Writer, payload: []const u8) !void { │ │597 +pub fn writePlainPacket(io: std.Io, writer: *std.Io.Writer, payload: []const u8) !void { │ │598 const block: usize = 8; │ │599 const initial_pad = block - ((5 + payload.len) % block); │ │600 const padding_len: u8 = @intCast(if (initial_pad < 4) initial_pad + block else initial_pad);│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯