diff --git a/kittens/remote_file/ssh.go b/kittens/remote_file/ssh.go new file mode 100644 index 000000000..ef9398d26 --- /dev/null +++ b/kittens/remote_file/ssh.go @@ -0,0 +1,107 @@ +// License: GPLv3 Copyright: 2026, Kovid Goyal, + +package remote_file + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Must match kittens/remote_file/main.py is_ssh_kitten_sentinel +const is_ssh_kitten_sentinel = `!#*&$#($ssh-kitten)(##$` + +type SSHConnectionData struct { + Binary string + Hostname string + Port int + IdentityFile string + IsSSHKitten bool + SSHKittenCmdline []string +} + +func parse_conn_data(raw string) (*SSHConnectionData, error) { + var items []any + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil, fmt.Errorf("invalid --ssh-connection-data: %w", err) + } + if len(items) < 2 { + return nil, fmt.Errorf("invalid --ssh-connection-data: need at least binary and hostname") + } + first, _ := items[0].(string) + ans := &SSHConnectionData{} + if first == is_ssh_kitten_sentinel { + // Python: SSHConnectionData(sentinel, cli_data[-1], -1, identity_file=json.dumps(cli_data[1:])) + // with the cmdline stripped of -t flags and its last two items. + ans.IsSSHKitten = true + ans.Hostname, _ = items[len(items)-1].(string) + cmdline := make([]string, 0, len(items)-1) + for _, x := range items[1:] { + s, _ := x.(string) + if s != "-t" { + cmdline = append(cmdline, s) + } + } + if len(cmdline) >= 2 { + cmdline = cmdline[:len(cmdline)-2] + } + ans.SSHKittenCmdline = cmdline + return ans, nil + } + ans.Binary = first + ans.Hostname, _ = items[1].(string) + if len(items) > 2 { + if p, ok := items[2].(float64); ok { + ans.Port = int(p) + } + } + if len(items) > 3 { + ans.IdentityFile, _ = items[3].(string) + } + return ans, nil +} + +func (c *SSHConnectionData) cmd_prefix() []string { + if c.IsSSHKitten { + return append([]string{}, c.SSHKittenCmdline...) + } + ans := []string{ + c.Binary, "-o", fmt.Sprintf("ControlPath=~/.ssh/kitty-rf-%d-%%C", os.Getpid()), + "-o", "TCPKeepAlive=yes", "-o", "ControlPersist=yes", + } + if c.Port > 0 { + ans = append(ans, "-p", fmt.Sprint(c.Port)) + } + if c.IdentityFile != "" { + ans = append(ans, "-i", c.IdentityFile) + } + return ans +} + +func (c *SSHConnectionData) batch_cmd_prefix() []string { + if c.IsSSHKitten { + return c.cmd_prefix() + } + return append(c.cmd_prefix(), "-o", "BatchMode=yes") +} + +func hostname_matches(from_hyperlink, actual string) bool { + if from_hyperlink == actual { + return true + } + fl, _, _ := strings.Cut(from_hyperlink, ".") + al, _, _ := strings.Cut(actual, ".") + return fl == al +} + +func auto_rename_dest(dest string, exists func(string) bool) string { + q := dest + ext := filepath.Ext(dest) + base := strings.TrimSuffix(dest, ext) + for c := 1; exists(q); c++ { + q = fmt.Sprintf("%s-%d%s", base, c, ext) + } + return q +} diff --git a/kittens/remote_file/ssh_test.go b/kittens/remote_file/ssh_test.go new file mode 100644 index 000000000..9ddefce5b --- /dev/null +++ b/kittens/remote_file/ssh_test.go @@ -0,0 +1,77 @@ +// License: GPLv3 Copyright: 2026, Kovid Goyal, + +package remote_file + +import ( + "fmt" + "os" + "slices" + "testing" +) + +func TestParseConnData(t *testing.T) { + c, err := parse_conn_data(`["ssh", "myhost", 2222, "/id/file"]`) + if err != nil { + t.Fatal(err) + } + if c.Binary != "ssh" || c.Hostname != "myhost" || c.Port != 2222 || c.IdentityFile != "/id/file" || c.IsSSHKitten { + t.Fatalf("bad conn data: %+v", c) + } + + c, err = parse_conn_data(`["ssh", "shorthost"]`) + if err != nil || c.Port != 0 || c.IdentityFile != "" { + t.Fatalf("optional fields: %+v err=%v", c, err) + } + + raw := fmt.Sprintf(`["%s", "kitten", "ssh", "-t", "somehost", "cmd"]`, is_ssh_kitten_sentinel) + c, err = parse_conn_data(raw) + if err != nil { + t.Fatal(err) + } + if !c.IsSSHKitten || c.Hostname != "cmd" || !slices.Equal(c.SSHKittenCmdline, []string{"kitten", "ssh"}) { + t.Fatalf("ssh kitten form: %+v", c) + } +} + +func TestCmdPrefixes(t *testing.T) { + c := &SSHConnectionData{Binary: "ssh", Hostname: "h", Port: 2222, IdentityFile: "/id"} + p := c.cmd_prefix() + want := []string{ + "ssh", "-o", fmt.Sprintf("ControlPath=~/.ssh/kitty-rf-%d-%%C", os.Getpid()), + "-o", "TCPKeepAlive=yes", "-o", "ControlPersist=yes", "-p", "2222", "-i", "/id", + } + if !slices.Equal(p, want) { + t.Fatalf("cmd_prefix:\n got %q\nwant %q", p, want) + } + b := c.batch_cmd_prefix() + if !slices.Equal(b, append(slices.Clone(want), "-o", "BatchMode=yes")) { + t.Fatalf("batch_cmd_prefix: %q", b) + } + sk := &SSHConnectionData{IsSSHKitten: true, SSHKittenCmdline: []string{"kitten", "ssh"}} + if !slices.Equal(sk.batch_cmd_prefix(), []string{"kitten", "ssh"}) { + t.Fatalf("ssh-kitten batch prefix must not add BatchMode: %q", sk.batch_cmd_prefix()) + } +} + +func TestHostnameMatches(t *testing.T) { + for _, tc := range []struct { + a, b string + want bool + }{ + {"foo", "foo", true}, + {"foo.example.com", "foo.local", true}, + {"foo", "bar", false}, + } { + if hostname_matches(tc.a, tc.b) != tc.want { + t.Fatalf("hostname_matches(%q, %q) != %v", tc.a, tc.b, tc.want) + } + } +} + +func TestAutoRenameDest(t *testing.T) { + taken := map[string]bool{"/x/a.txt": true, "/x/a-1.txt": true} + got := auto_rename_dest("/x/a.txt", func(p string) bool { return taken[p] }) + if got != "/x/a-2.txt" { + t.Fatalf("auto_rename_dest: %q", got) + } +}