╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ serve_ssh.zig ────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
157 try writeError(sess, "forbidden path"); 158 try sess.exit(1); 159 return; 160 } 161 162 const repo_opts: rp.RepoOpts(.xit) = .{}; 163 const Repo = rp.Repo(.xit, repo_opts); 164 const create_if_missing = parsed.service == .receive_pack; 165 166 var repo = openOrCreate(Repo, io, allocator, repo_path, create_if_missing) catch |err| switch (err) { 167 error.RepoNotFound => { 168 try writeError(sess, "repo not found"); 169 try sess.exit(1); 170 return; 171 }, 172 else => |e| return e, 173 }; 174 defer repo.deinit(io, allocator); 175 176 // wrap the channel I/O in std.Io.Reader/Writer adapters so the repo's 177 // pack functions can consume them like any other stream. 178 var session_reader_buf: [4096]u8 = undefined; 179 var session_writer_buf: [4096]u8 = undefined; 180 var session_reader = ssh.SessionReader.init(sess, &session_reader_buf); 181 var session_writer = ssh.SessionWriter.init(sess, &session_writer_buf); 182 183 switch (parsed.service) { 184 .upload_pack => try repo.uploadPack(io, allocator, &session_reader.interface, &session_writer.interface, .{}), 185 .receive_pack => try repo.receivePack(io, allocator, &session_reader.interface, &session_writer.interface, .{}), 186 } 187 188 // flush whatever the pack op buffered into our writer adapter 189 session_writer.interface.flush() catch {}; 190 191 try sess.exit(0); 192 } 193 194 fn openOrCreate( 195 comptime Repo: type, 196 io: std.Io, 197 allocator: std.mem.Allocator, 198 repo_path: []const u8, 199 create_if_missing: bool, 200 ) !Repo { 201 return Repo.open(io, allocator, .{ .path = repo_path }) catch |err| switch (err) { 202 error.RepoNotFound => { 203 if (!create_if_missing) return err; 204 var repo = try Repo.init(io, allocator, .{ .path = repo_path }); 205 errdefer repo.deinit(io, allocator); 206 try repo.addConfig(io, allocator, .{ .name = "http.receivepack", .value = "true" }); 207 try repo.addConfig(io, allocator, .{ .name = "receive.denycurrentbranch", .value = "updateinstead" }); 208 return repo; 209 }, 210 else => |e| return e, 211 }; 212 } 213 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 218 const service_token = tokens.next() orelse return error.InvalidCommand; 219 const dir_token = tokens.next() orelse return error.InvalidCommand; 220 221 const service: GitService = 222 if (std.mem.eql(u8, service_token, "git-upload-pack") or std.mem.eql(u8, service_token, "upload-pack")) 223 .upload_pack 224 else if (std.mem.eql(u8, service_token, "git-receive-pack") or std.mem.eql(u8, service_token, "receive-pack")) 225 .receive_pack 226 else 227 return error.UnsupportedService; 228 229 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; 230 } 231 232 fn resolveRepoPath(allocator: std.mem.Allocator, repo_root_path: []const u8, dir: []const u8) ![]const u8 { 233 if (std.fs.path.isAbsolute(dir)) return try std.fs.path.resolve(allocator, &.{dir}); 234 return try std.fs.path.resolve(allocator, &.{ repo_root_path, dir }); 235 } 236 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; 241 } 242 243 fn writeError(sess: *ssh.SessionCtx, msg: []const u8) !void { 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