╭ changed ───────╮ │src/test/ssh.zig│ ╰────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────╮ │1 const std = @import("std"); │ │2 -const builtin = @import("builtin"); │ │2 const hx = @import("haxy"); │ │3 const ssh = hx.serve_ssh; │ │4 const proto = hx.serve_ssh_protocol;│ ╰───────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │ 5 +const Ed25519 = std.crypto.sign.Ed25519; │ │ 6 +const X25519 = std.crypto.dh.X25519; │ │ 7 +const Sha256 = std.crypto.hash.sha2.Sha256; │ │ 8 │ │ 9 test "fingerprint format matches openssh layout" { │ │ 8 - // a deterministic ed25519 public key — using a fixed seed so the test│ │ 9 - // output stays stable across runs. │ │10 - const Ed25519 = std.crypto.sign.Ed25519; │ │10 const seed = [_]u8{0x42} ** Ed25519.KeyPair.seed_length; │ │11 const keypair = try Ed25519.KeyPair.generateDeterministic(seed); │ │12 │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │13 // SSH wire-format pubkey blob: string "ssh-ed25519" || string raw_pubkey. │ │15 - // mirrors what HostKey.appendPublicBlob writes and what │ │16 - // verifyUserauthSignature receives from the client. │ │14 const algo = "ssh-ed25519"; │ │15 var blob: [4 + algo.len + 4 + Ed25519.PublicKey.encoded_length]u8 = undefined;│ │16 std.mem.writeInt(u32, blob[0..4], algo.len, .big); │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │20 │ │21 const formatted = proto.formatFingerprint(&blob); │ │22 │ │26 - // exact shape openssh prints in `Offering public key: … SHA256:…`│ │23 try std.testing.expectEqual(50, formatted.len); │ │24 try std.testing.expectEqualStrings("SHA256:", formatted[0..7]); │ │25 │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │30 - // the body is 43 base64-no-padding chars (= ceil(32 * 4 / 3)). decoding │ │31 - // it back must yield exactly the SHA256 of the input blob — this is │ │32 - // the actual security/identity property other code relies on. │ │33 - var decoded: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; │ │26 + // the body must decode to the SHA256 of the input blob. │ │27 + var decoded: [Sha256.digest_length]u8 = undefined; │ │28 try std.base64.standard_no_pad.Decoder.decode(&decoded, formatted[7..]); │ │35 - var expected: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; │ │36 - std.crypto.hash.sha2.Sha256.hash(&blob, &expected, .{}); │ │29 + var expected: [Sha256.digest_length]u8 = undefined; │ │30 + Sha256.hash(&blob, &expected, .{}); │ │31 try std.testing.expectEqualSlices(u8, &expected, &decoded); │ │38 -} │ │39 - │ │40 -test "isSubPath rejects prefix collisions and traversal" { │ │41 - const sep = std.fs.path.sep_str; │ │42 - const parent = sep ++ "srv" ++ sep ++ "git"; │ │43 - │ │44 - // simple containment │ │45 - try std.testing.expect(ssh.isSubPath(parent, parent ++ sep ++ "repo")); │ │46 - try std.testing.expect(ssh.isSubPath(parent, parent)); │ │47 - │ │48 - // common-prefix-but-not-subpath: "/srv/git" should NOT swallow │ │49 - // "/srv/git2" or "/srv/gitignore" just because the bytes start the same. │ │50 - try std.testing.expect(!ssh.isSubPath(parent, parent ++ "2")); │ │51 - try std.testing.expect(!ssh.isSubPath(parent, parent ++ "ignore")); │ │52 - │ │53 - // the resolveRepoPath caller passes a `..`-normalized child, so the │ │54 - // resolved path of e.g. `/srv/git/../etc/passwd` is just `/etc/passwd` │ │55 - // — isSubPath then correctly rejects it. │ │56 - try std.testing.expect(!ssh.isSubPath(parent, sep ++ "etc" ++ sep ++ "passwd")); │ │57 - │ │58 - // root-as-parent (only meaningful on POSIX) │ │59 - if (.windows != builtin.os.tag) { │ │60 - try std.testing.expect(ssh.isSubPath(sep, sep ++ "anything")); │ │61 - try std.testing.expect(ssh.isSubPath(sep, sep)); │ │62 - } │ │63 -} │ │64 - │ │65 -test "parseGitCommand happy paths" { │ │66 - const allocator = std.testing.allocator; │ │67 - │ │68 - { │ │69 - const parsed = try ssh.parseGitCommand(allocator, "git-upload-pack 'some-repo'"); │ │70 - defer parsed.deinit(allocator); │ │71 - try std.testing.expectEqual(ssh.GitService.upload_pack, parsed.service); │ │72 - try std.testing.expectEqualStrings("some-repo", parsed.dir); │ │73 - } │ │74 - │ │75 - { │ │76 - const parsed = try ssh.parseGitCommand(allocator, "git-receive-pack 'user/proj'"); │ │77 - defer parsed.deinit(allocator); │ │78 - try std.testing.expectEqual(ssh.GitService.receive_pack, parsed.service); │ │79 - try std.testing.expectEqualStrings("user/proj", parsed.dir); │ │80 - } │ │81 - │ │82 - // bare (no quotes) form: git accepts it, so we should too │ │83 - { │ │84 - const parsed = try ssh.parseGitCommand(allocator, "git-upload-pack repo"); │ │85 - defer parsed.deinit(allocator); │ │86 - try std.testing.expectEqual(ssh.GitService.upload_pack, parsed.service); │ │87 - try std.testing.expectEqualStrings("repo", parsed.dir); │ │88 - } │ │89 -} │ │90 - │ │91 -test "parseGitCommand rejects malformed input" { │ │92 - const allocator = std.testing.allocator; │ │93 - │ │94 - // missing dir argument │ │95 - try std.testing.expectError(error.InvalidCommand, ssh.parseGitCommand(allocator, "git-upload-pack")); │ │96 - │ │97 - // unknown service — only git-upload-pack and git-receive-pack are dispatched │ │98 - try std.testing.expectError(error.UnsupportedService, ssh.parseGitCommand(allocator, "ls -la")); │ │99 - try std.testing.expectError(error.UnsupportedService, ssh.parseGitCommand(allocator, "git-fake-pack 'repo'"));│ │32 } │ │33 │ │34 test "channel data parser rejects malformed payloads" { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────╮ │ 35 const allocator = std.testing.allocator;│ │104 - const SSH_MSG_CHANNEL_DATA: u8 = 94; │ │ 36 │ │ 37 var packet = std.ArrayList(u8).empty; │ │ 38 defer packet.deinit(allocator); │ ╰─────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │108 - try packet.append(allocator, SSH_MSG_CHANNEL_DATA); │ │109 - try appendU32(allocator, &packet, 0); │ │110 - try appendU32(allocator, &packet, 3); │ │ 39 + try packet.append(allocator, proto.SSH_MSG_CHANNEL_DATA); │ │ 40 + try proto.writeU32(&packet, allocator, 0); │ │ 41 + try proto.writeU32(&packet, allocator, 3); │ │ 42 try packet.appendSlice(allocator, "abc"); │ │ 43 │ │113 - // valid form first — confirms the wire layout we're constructing │ │ 44 try std.testing.expectEqualStrings("abc", try proto.parseChannelData(packet.items, false, 0)); │ │ 45 │ │116 - // wrong recipient channel — flip the last byte of the channel-id field │ │117 - // in place; we're done with the success case above. │ │ 46 + // wrong recipient channel │ │ 47 packet.items[4] = 1; │ │ 48 try std.testing.expectError(error.UnknownChannel, proto.parseChannelData(packet.items, false, 0));│ │ 49 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │121 - // declared length larger than the packet actually carries │ │ 50 + // declared length larger than the packet carries │ │ 51 var too_long = std.ArrayList(u8).empty; │ │ 52 defer too_long.deinit(allocator); │ │124 - try too_long.append(allocator, SSH_MSG_CHANNEL_DATA); │ │125 - try appendU32(allocator, &too_long, 0); │ │126 - try appendU32(allocator, &too_long, 4); │ │ 53 + try too_long.append(allocator, proto.SSH_MSG_CHANNEL_DATA); │ │ 54 + try proto.writeU32(&too_long, allocator, 0); │ │ 55 + try proto.writeU32(&too_long, allocator, 4); │ │ 56 try too_long.appendSlice(allocator, "abc"); │ │ 57 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(too_long.items, false, 0));│ │ 58 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 59 // declared length smaller than the packet carries (trailing junk) │ │ 60 var trailing = std.ArrayList(u8).empty; │ │ 61 defer trailing.deinit(allocator); │ │133 - try trailing.append(allocator, SSH_MSG_CHANNEL_DATA); │ │134 - try appendU32(allocator, &trailing, 0); │ │135 - try appendU32(allocator, &trailing, 3); │ │ 62 + try trailing.append(allocator, proto.SSH_MSG_CHANNEL_DATA); │ │ 63 + try proto.writeU32(&trailing, allocator, 0); │ │ 64 + try proto.writeU32(&trailing, allocator, 3); │ │ 65 try trailing.appendSlice(allocator, "abcd"); │ │ 66 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(trailing.items, false, 0));│ │ 67 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────╮ │ 68 │ │ 69 test "extended channel data parser accepts only stderr type" {│ │ 70 const allocator = std.testing.allocator; │ │142 - const SSH_MSG_CHANNEL_EXTENDED_DATA: u8 = 95; │ │143 - const SSH_EXTENDED_DATA_STDERR: u32 = 1; │ │ 71 │ │ 72 var packet = std.ArrayList(u8).empty; │ │ 73 defer packet.deinit(allocator); │ ╰───────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯