mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-21 20:52:19 +00:00
remote_file kitten: Go connection-data parsing and ssh helpers
This commit is contained in:
parent
7e5c773df1
commit
25c3347d38
2 changed files with 184 additions and 0 deletions
107
kittens/remote_file/ssh.go
Normal file
107
kittens/remote_file/ssh.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
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
|
||||
}
|
||||
77
kittens/remote_file/ssh_test.go
Normal file
77
kittens/remote_file/ssh_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue