╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ serve_ssh.zig ────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
229 var session_writer = ssh.SessionWriter.init(sess, &session_writer_buf); 230 231 switch (service) { 232 .upload_pack => try repo.uploadPack(io, allocator, &session_reader.interface, &session_writer.interface, .{}), 233 .receive_pack => try repo.receivePack(io, allocator, &session_reader.interface, &session_writer.interface, .{}), 234 } 235 236 // flush whatever the pack op buffered into our writer adapter 237 session_writer.interface.flush() catch {}; 238 } 239 240 fn parseGitCommand(allocator: std.mem.Allocator, command: []const u8) !ParsedGitCommand { 241 var tokens = try std.process.Args.IteratorGeneral(.{ .single_quotes = true }).init(allocator, command); 242 defer tokens.deinit(); 243 244 const service_token = tokens.next() orelse return error.InvalidCommand; 245 const dir_token = tokens.next() orelse return error.InvalidCommand; 246 247 const service: GitService = 248 if (std.mem.eql(u8, service_token, "git-upload-pack") or std.mem.eql(u8, service_token, "upload-pack")) 249 .upload_pack 250 else if (std.mem.eql(u8, service_token, "git-receive-pack") or std.mem.eql(u8, service_token, "receive-pack")) 251 .receive_pack 252 else 253 return error.UnsupportedService; 254 255 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; 256 } 257 258 fn resolveRepoPath(allocator: std.mem.Allocator, repo_root_path: []const u8, dir: []const u8) ![]const u8 { 259 if (std.fs.path.isAbsolute(dir)) return try std.fs.path.resolve(allocator, &.{dir}); 260 return try std.fs.path.resolve(allocator, &.{ repo_root_path, dir }); 261 } 262 263 fn isSubPath(parent: []const u8, child: []const u8) bool { 264 if (std.mem.eql(u8, parent, std.fs.path.sep_str)) return std.fs.path.isAbsolute(child); 265 if (!std.mem.startsWith(u8, child, parent)) return false; 266 return child.len == parent.len or child[parent.len] == std.fs.path.sep; 267 } 268 269 fn writeError(sess: *ssh.SessionCtx, msg: []const u8) !void { 270 var buf: [256]u8 = undefined; 271 const text = try std.fmt.bufPrint(&buf, "haxy ssh: {s}\n", .{msg}); 272 try sess.writeBytes(text); 273 } 274 275 test "parseGitCommand rejects malformed input" { 276 const allocator = std.testing.allocator; 277 try std.testing.expectError(error.InvalidCommand, parseGitCommand(allocator, "git-upload-pack")); 278 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "ls -la")); 279 try std.testing.expectError(error.UnsupportedService, parseGitCommand(allocator, "git-fake-pack 'repo'")); 280 } 281 282 test "parseGitCommand happy paths" { 283 const allocator = std.testing.allocator; 284 285 { 286 const parsed = try parseGitCommand(allocator, "git-upload-pack 'some-repo'"); 287 defer parsed.deinit(allocator); 288 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 289 try std.testing.expectEqualStrings("some-repo", parsed.dir); 290 } 291 { 292 const parsed = try parseGitCommand(allocator, "git-receive-pack 'user/proj'"); 293 defer parsed.deinit(allocator); 294 try std.testing.expectEqual(GitService.receive_pack, parsed.service); 295 try std.testing.expectEqualStrings("user/proj", parsed.dir); 296 } 297 { 298 const parsed = try parseGitCommand(allocator, "git-upload-pack repo"); 299 defer parsed.deinit(allocator); 300 try std.testing.expectEqual(GitService.upload_pack, parsed.service); 301 try std.testing.expectEqualStrings("repo", parsed.dir); 302 } 303 } 304 305 test "isSubPath rejects prefix collisions and traversal" { 306 const sep = std.fs.path.sep_str; 307 const parent = sep ++ "srv" ++ sep ++ "git"; 308 309 try std.testing.expect(isSubPath(parent, parent ++ sep ++ "repo")); 310 try std.testing.expect(isSubPath(parent, parent)); 311 312 // common-prefix-but-not-subpath: "/srv/git" should NOT swallow 313 // "/srv/git2" or "/srv/gitignore" just because the bytes start the same. 314 try std.testing.expect(!isSubPath(parent, parent ++ "2")); 315 try std.testing.expect(!isSubPath(parent, parent ++ "ignore")); 316 317 // resolveRepoPath normalizes `..` first, so `/srv/git/../etc/passwd` 318 // becomes `/etc/passwd` — isSubPath then rejects it. 319 try std.testing.expect(!isSubPath(parent, sep ++ "etc" ++ sep ++ "passwd")); 320 321 const builtin = @import("builtin"); 322 323 if (.windows != builtin.os.tag) { 324 try std.testing.expect(isSubPath(sep, sep ++ "anything")); 325 try std.testing.expect(isSubPath(sep, sep)); 326 } 327 } 328