remote_file kitten: Go connection-data parsing and ssh helpers

This commit is contained in:
OBrutus 2026-08-12 16:32:53 +05:30
parent 7e5c773df1
commit 25c3347d38
2 changed files with 184 additions and 0 deletions

107
kittens/remote_file/ssh.go Normal file
View 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
}

View 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)
}
}