╭ ssh.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const hx = @import("haxy"); 4 const ssh = hx.serve_ssh; 5 const proto = hx.serve_ssh_protocol; 6 7 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; 11 const seed = [_]u8{0x42} ** Ed25519.KeyPair.seed_length; 12 const keypair = try Ed25519.KeyPair.generateDeterministic(seed); 13 14 // 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. 17 const algo = "ssh-ed25519"; 18 var blob: [4 + algo.len + 4 + Ed25519.PublicKey.encoded_length]u8 = undefined; 19 std.mem.writeInt(u32, blob[0..4], algo.len, .big); 20 @memcpy(blob[4 .. 4 + algo.len], algo); 21 std.mem.writeInt(u32, blob[4 + algo.len ..][0..4], Ed25519.PublicKey.encoded_length, .big); 22 @memcpy(blob[4 + algo.len + 4 ..], &keypair.public_key.bytes); 23 24 const formatted = proto.formatFingerprint(&blob); 25 26 // exact shape openssh prints in `Offering public key: … SHA256:…` 27 try std.testing.expectEqual(50, formatted.len); 28 try std.testing.expectEqualStrings("SHA256:", formatted[0..7]); 29 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; 34 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, .{}); 37 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'")); 100 } 101 102 test "channel data parser rejects malformed payloads" { 103 const allocator = std.testing.allocator; 104 const SSH_MSG_CHANNEL_DATA: u8 = 94; 105 106 var packet = std.ArrayList(u8).empty; 107 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); 111 try packet.appendSlice(allocator, "abc"); 112 113 // valid form first — confirms the wire layout we're constructing 114 try std.testing.expectEqualStrings("abc", try proto.parseChannelData(packet.items, false, 0)); 115 116 // wrong recipient channel — flip the last byte of the channel-id field 117 // in place; we're done with the success case above. 118 packet.items[4] = 1; 119 try std.testing.expectError(error.UnknownChannel, proto.parseChannelData(packet.items, false, 0)); 120 121 // declared length larger than the packet actually carries 122 var too_long = std.ArrayList(u8).empty; 123 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); 127 try too_long.appendSlice(allocator, "abc"); 128 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(too_long.items, false, 0)); 129 130 // declared length smaller than the packet carries (trailing junk) 131 var trailing = std.ArrayList(u8).empty; 132 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); 136 try trailing.appendSlice(allocator, "abcd"); 137 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(trailing.items, false, 0)); 138 } 139 140 test "extended channel data parser accepts only stderr type" { 141 const allocator = std.testing.allocator; 142 const SSH_MSG_CHANNEL_EXTENDED_DATA: u8 = 95; 143 const SSH_EXTENDED_DATA_STDERR: u32 = 1; 144 145 var packet = std.ArrayList(u8).empty; 146 defer packet.deinit(allocator); 147 try packet.append(allocator, SSH_MSG_CHANNEL_EXTENDED_DATA); 148 try appendU32(allocator, &packet, 0); 149 try appendU32(allocator, &packet, SSH_EXTENDED_DATA_STDERR); 150 try appendU32(allocator, &packet, 3); 151 try packet.appendSlice(allocator, "err"); 152 153 // stderr (data_type == 1) is accepted 154 try std.testing.expectEqualStrings("err", try proto.parseChannelData(packet.items, true, 0)); 155 156 // any other data_type code is rejected — flip the last byte of the 157 // data_type field in place. 158 packet.items[8] = 2; 159 try std.testing.expectError(error.UnsupportedExtendedData, proto.parseChannelData(packet.items, true, 0)); 160 } 161 162 fn appendU32(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), value: u32) !void { 163 var bytes: [4]u8 = undefined; 164 std.mem.writeInt(u32, &bytes, value, .big); 165 try buf.appendSlice(allocator, &bytes); 166 } 167