╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭ added ─────────────╮ │src/serve_common.zig│ ╰────────────────────╯ ╭ added ───────────╮ │src/serve_http.zig│ ╰──────────────────╯