╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 + │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯