//go:build !plan9

package sftp

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/rsa"
	"encoding/base64"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"testing"

	"github.com/rclone/rclone/fs"
	"github.com/rclone/rclone/fs/config/configmap"
	"github.com/rclone/rclone/fs/config/obscure"
	"github.com/rclone/rclone/fstest/fstests"
	"github.com/rclone/rclone/lib/encoder"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
)

func TestShellEscapeUnix(t *testing.T) {
	for i, test := range []struct {
		unescaped, escaped string
	}{
		{"", ""},
		{"/this/is/harmless", "/this/is/harmless"},
		{"$(rm -rf /)", "\\$\\(rm\\ -rf\\ /\\)"},
		{"/test/\n", "/test/'\n'"},
		{":\"'", ":\\\"\\'"},
		// a backslash must be escaped so it cannot neutralise the escape
		// of the metacharacter that follows it
		{"a\\;id", "a\\\\\\;id"},
		{"`id`", "\\`id\\`"},
	} {
		got, err := quoteOrEscapeShellPath("unix", test.unescaped)
		assert.NoError(t, err)
		assert.Equal(t, test.escaped, got, fmt.Sprintf("Test %d unescaped = %q", i, test.unescaped))
	}
}

func TestShellEscapeCmd(t *testing.T) {
	for i, test := range []struct {
		unescaped, escaped string
		ok                 bool
	}{
		{"", "\"\"", true},
		{"c:/this/is/harmless", "\"c:/this/is/harmless\"", true},
		// & < > | ^ are not special inside cmd double quotes so are allowed
		{"c:/test&notepad", "\"c:/test&notepad\"", true},
		{"c:/test\"&\"notepad", "", false},
		// % and ! expand environment variables even inside double quotes
		{"c:/test%PATH%notepad", "", false},
		{"c:/test!PATH!notepad", "", false},
		// a newline or carriage return ends the command
		{"c:/test\nnotepad", "", false},
		{"c:/test\rnotepad", "", false},
	} {
		got, err := quoteOrEscapeShellPath("cmd", test.unescaped)
		if test.ok {
			assert.NoError(t, err)
			assert.Equal(t, test.escaped, got, fmt.Sprintf("Test %d unescaped = %q", i, test.unescaped))
		} else {
			assert.Error(t, err)
		}
	}
}

func TestShellEscapePowerShell(t *testing.T) {
	// PowerShell treats U+2018, U+2019, U+201A and U+201B as single-quote
	// delimiters in addition to the ASCII apostrophe, so all of them are
	// doubled inside the wrapping apostrophes. Doubling is PowerShell's escape
	// for a literal delimiter and preserves the exact character.
	unquote := strings.NewReplacer("''", "'", "‘‘", "‘", "’’", "’", "‚‚", "‚", "‛‛", "‛")
	for i, test := range []struct {
		unescaped, escaped string
	}{
		{"", "''"},
		{"c:/this/is/harmless", "'c:/this/is/harmless'"},
		{"c:/test&notepad", "'c:/test&notepad'"},
		{"c:/test\"&\"notepad", "'c:/test\"&\"notepad'"},
		{"c:/test'&'notepad", "'c:/test''&''notepad'"},
		// injection attempts via the ASCII apostrophe and each smart quote
		{"x';calc;#", "'x'';calc;#'"},
		{"x’;calc;#", "'x’’;calc;#'"},
		{"x‘;calc;#", "'x‘‘;calc;#'"},
		{"x‚;calc;#", "'x‚‚;calc;#'"},
		{"x‛;calc;#", "'x‛‛;calc;#'"},
	} {
		got, err := quoteOrEscapeShellPath("powershell", test.unescaped)
		assert.NoError(t, err)
		assert.Equal(t, test.escaped, got, fmt.Sprintf("Test %d unescaped = %q", i, test.unescaped))
		// Every single-quote delimiter must appear an even number of times so
		// none is left unpaired to close the literal early.
		for _, q := range []string{"'", "‘", "’", "‚", "‛"} {
			assert.Zero(t, strings.Count(got, q)%2, fmt.Sprintf("Test %d odd %q count in %q", i, q, got))
		}
		// Undoubling the quoted body recovers the original path exactly.
		assert.Equal(t, test.unescaped, unquote.Replace(got[1:len(got)-1]), fmt.Sprintf("Test %d round-trip", i))
	}
}

func TestRemotePathEncodesRemoteNames(t *testing.T) {
	f := &Fs{
		absRoot: "/srv/root",
		opt: Options{
			Enc: encoder.Display | encoder.EncodeColon,
		},
	}

	assert.Equal(t, "/srv/root/dir/file\uFF1Aname", f.remotePath("dir/file:name"))
	assert.Equal(t, "/srv/root", f.remotePath(""))
}

func TestRemoteShellPathEncodesRemoteNames(t *testing.T) {
	f := &Fs{
		absRoot: "/srv/root",
		opt: Options{
			Enc: encoder.Display | encoder.EncodeColon,
		},
	}

	assert.Equal(t, "/srv/root/dir/file\uFF1Aname", f.remoteShellPath("dir/file:name"))
}

func TestRemoteShellPathEncodesPathOverrideNames(t *testing.T) {
	f := &Fs{
		absRoot: "/srv/root",
		opt: Options{
			Enc:          encoder.Display | encoder.EncodeColon,
			PathOverride: "/shell/root",
		},
	}

	assert.Equal(t, "/shell/root/dir/file\uFF1Aname", f.remoteShellPath("dir/file:name"))

	f.opt.PathOverride = "@/volume"
	assert.Equal(t, "/volume/srv/root/dir/file\uFF1Aname", f.remoteShellPath("dir/file:name"))
}

func TestParseHash(t *testing.T) {
	for i, test := range []struct {
		sshOutput, checksum string
	}{
		{"8dbc7733dbd10d2efc5c0a0d8dad90f958581821  RELEASE.md\n", "8dbc7733dbd10d2efc5c0a0d8dad90f958581821"},
		{"03cfd743661f07975fa2f1220c5194cbaff48451  -\n", "03cfd743661f07975fa2f1220c5194cbaff48451"},
	} {
		got := parseHash([]byte(test.sshOutput))
		assert.Equal(t, test.checksum, got, fmt.Sprintf("Test %d sshOutput = %q", i, test.sshOutput))
	}
}

// fakePublicKey is a minimal ssh.PublicKey for tests. Marshal() is the only
// method exercised by the host-key code (FingerprintSHA256 sha256s it).
type fakePublicKey struct {
	keyType string
	data    []byte
}

func (k *fakePublicKey) Type() string                            { return k.keyType }
func (k *fakePublicKey) Marshal() []byte                         { return k.data }
func (k *fakePublicKey) Verify(_ []byte, _ *ssh.Signature) error { return nil }

// makeTestKeys returns n distinct marshalled ed25519 public keys.
func makeTestKeys(t *testing.T, n int) [][]byte {
	keys := make([][]byte, n)
	for i := range keys {
		pub, _, err := ed25519.GenerateKey(rand.Reader)
		require.NoError(t, err)
		sshPub, err := ssh.NewPublicKey(pub)
		require.NoError(t, err)
		keys[i] = sshPub.Marshal()
	}
	return keys
}

// makeTestRSAKey returns a marshalled RSA public key.
func makeTestRSAKey(t *testing.T) []byte {
	priv, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)
	sshPub, err := ssh.NewPublicKey(&priv.PublicKey)
	require.NoError(t, err)
	return sshPub.Marshal()
}

func TestParseHostKeysField(t *testing.T) {
	keys := makeTestKeys(t, 2)
	keyA, keyB := keys[0], keys[1]
	encA := base64.StdEncoding.EncodeToString(keyA)
	encB := base64.StdEncoding.EncodeToString(keyB)
	rsaKey := makeTestRSAKey(t)
	encRSA := base64.StdEncoding.EncodeToString(rsaKey)

	for _, test := range []struct {
		name    string
		input   fs.CommaSepList
		want    map[string][][]byte
		wantErr string
	}{
		{name: "Empty", input: fs.CommaSepList{}, want: map[string][][]byte{}},
		{name: "EmptyEntrySkipped", input: fs.CommaSepList{""}, want: map[string][][]byte{}},
		{name: "Single", input: fs.CommaSepList{"ssh-ed25519 " + encA}, want: map[string][][]byte{"ssh-ed25519": {keyA}}},
		{
			name:  "MultiAlgo",
			input: fs.CommaSepList{"ssh-ed25519 " + encA, "ssh-rsa " + encRSA},
			want:  map[string][][]byte{"ssh-ed25519": {keyA}, "ssh-rsa": {rsaKey}},
		},
		{
			name:  "MultiKeyPerAlgo",
			input: fs.CommaSepList{"ssh-ed25519 " + encA, "ssh-ed25519 " + encB},
			want:  map[string][][]byte{"ssh-ed25519": {keyA, keyB}},
		},
		{
			name:  "EntryLevelWhitespaceTrimmed",
			input: fs.CommaSepList{"  ssh-ed25519 " + encA + "  ", " ssh-rsa " + encRSA + "\t"},
			want:  map[string][][]byte{"ssh-ed25519": {keyA}, "ssh-rsa": {rsaKey}},
		},
		{
			name:  "DuplicatesDropped",
			input: fs.CommaSepList{"ssh-ed25519 " + encA, "ssh-ed25519 " + encA},
			want:  map[string][][]byte{"ssh-ed25519": {keyA}},
		},
		{name: "MalformedTooFewFields", input: fs.CommaSepList{"ssh-ed25519"}, wantErr: "expected"},
		{name: "MalformedTooManyFields", input: fs.CommaSepList{"ssh-ed25519 " + encA + " trailing"}, wantErr: "expected"},
		{name: "MalformedBase64", input: fs.CommaSepList{"ssh-ed25519 not-base64-!!!"}, wantErr: "base64"},
		{
			name:    "NotAPublicKey",
			input:   fs.CommaSepList{"ssh-ed25519 " + base64.StdEncoding.EncodeToString([]byte("junk"))},
			wantErr: "not a valid SSH public key",
		},
		{
			// The stated algorithm must be the key's own format name.
			name:    "AlgoMismatch",
			input:   fs.CommaSepList{"ssh-rsa " + encA},
			wantErr: `doesn't match the key's type "ssh-ed25519"`,
		},
		{
			// rsa-sha2-* are signature algorithms; an RSA key blob's
			// format is ssh-rsa, so hint at the correct spelling.
			name:    "RSASignatureAlgorithmHint",
			input:   fs.CommaSepList{"rsa-sha2-256 " + encRSA},
			wantErr: "use ssh-rsa",
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			got, err := parseHostKeysField(test.input)
			if test.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), test.wantErr)
				return
			}
			require.NoError(t, err)
			assert.Equal(t, test.want, got)
		})
	}

	t.Run("Certificate", func(t *testing.T) {
		_, priv, err := ed25519.GenerateKey(rand.Reader)
		require.NoError(t, err)
		signer, err := ssh.NewSignerFromKey(priv)
		require.NoError(t, err)
		cert := &ssh.Certificate{Key: signer.PublicKey(), CertType: ssh.HostCert, ValidBefore: ssh.CertTimeInfinity}
		require.NoError(t, cert.SignCert(rand.Reader, signer))
		entry := cert.Type() + " " + base64.StdEncoding.EncodeToString(cert.Marshal())
		_, err = parseHostKeysField(fs.CommaSepList{entry})
		require.Error(t, err)
		assert.Contains(t, err.Error(), "certificate")
	})

	t.Run("TooManyEntries", func(t *testing.T) {
		var entries fs.CommaSepList
		for _, key := range makeTestKeys(t, maxHostKeys+1) {
			entries = append(entries, "ssh-ed25519 "+base64.StdEncoding.EncodeToString(key))
		}
		_, err := parseHostKeysField(entries)
		require.Error(t, err)
		assert.Contains(t, err.Error(), "more than")
		// Trimming to the cap parses fine.
		_, err = parseHostKeysField(entries[:maxHostKeys])
		require.NoError(t, err)
	})
}

func TestFormatHostKeysFieldRoundTrip(t *testing.T) {
	// Pre-sort the ed25519 keys so the expected output can be written down.
	edKeys := makeTestKeys(t, 3)
	sort.Slice(edKeys, func(i, j int) bool { return bytes.Compare(edKeys[i], edKeys[j]) < 0 })
	rsaKey := makeTestRSAKey(t)
	canonical := map[string][][]byte{
		"ssh-ed25519": {edKeys[0], edKeys[1], edKeys[2]},
		"ssh-rsa":     {rsaKey},
	}
	formatted := formatHostKeysField(canonical)
	// Deterministic: algos sorted, keys within algo sorted by bytes.
	expected := fs.CommaSepList{
		"ssh-ed25519 " + base64.StdEncoding.EncodeToString(edKeys[0]),
		"ssh-ed25519 " + base64.StdEncoding.EncodeToString(edKeys[1]),
		"ssh-ed25519 " + base64.StdEncoding.EncodeToString(edKeys[2]),
		"ssh-rsa " + base64.StdEncoding.EncodeToString(rsaKey),
	}
	assert.Equal(t, expected, formatted)
	// The CommaSepList wire form joins entries with commas.
	assert.Equal(t, strings.Join(expected, ","), formatted.String())

	parsed, err := parseHostKeysField(formatted)
	require.NoError(t, err)
	assert.Equal(t, canonical, parsed)

	// Format is independent of input ordering.
	scrambled := map[string][][]byte{
		"ssh-rsa":     {rsaKey},
		"ssh-ed25519": {edKeys[2], edKeys[0], edKeys[1]},
	}
	assert.Equal(t, expected, formatHostKeysField(scrambled))
}

func TestPinnedHostKeyAlgorithms(t *testing.T) {
	for _, test := range []struct {
		name  string
		input map[string][][]byte
		want  []string
	}{
		{name: "Empty", input: map[string][][]byte{}, want: []string{}},
		{
			name:  "NonRSA",
			input: map[string][][]byte{"ssh-ed25519": {{0x01}}, "ecdsa-sha2-nistp256": {{0x02}}},
			want:  []string{"ecdsa-sha2-nistp256", "ssh-ed25519"},
		},
		{
			// "ssh-rsa" is a key format, not just a signature algorithm, so
			// it must expand to the rsa-sha2 signature algorithms too.
			name:  "RSAExpanded",
			input: map[string][][]byte{"ssh-rsa": {{0x01}}},
			want:  []string{"rsa-sha2-256", "rsa-sha2-512", "ssh-rsa"},
		},
		{
			name:  "RSAAndOthers",
			input: map[string][][]byte{"ssh-rsa": {{0x01}}, "ssh-ed25519": {{0x02}}},
			want:  []string{"rsa-sha2-256", "rsa-sha2-512", "ssh-ed25519", "ssh-rsa"},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			assert.Equal(t, test.want, pinnedHostKeyAlgorithms(test.input))
		})
	}
}

func TestHostKeyCallbackValidateMatch(t *testing.T) {
	keyBytes := []byte("trusted-key-marshalled-bytes")
	f := &Fs{
		hostKeys: map[string][][]byte{"ssh-ed25519": {keyBytes}},
	}
	cb := f.hostKeyCallback(nil)
	err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyBytes})
	assert.NoError(t, err)
}

func TestHostKeyCallbackValidateMismatch(t *testing.T) {
	pinned := []byte("pinned-key-bytes")
	offered := []byte("DIFFERENT-key-bytes")
	f := &Fs{
		hostKeys: map[string][][]byte{"ssh-ed25519": {pinned}},
	}
	cb := f.hostKeyCallback(nil)
	err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: offered})
	require.Error(t, err)
	// Mismatch message should include both fingerprints and a how-to hint.
	assert.Contains(t, err.Error(), "host key mismatch")
	assert.Contains(t, err.Error(), "--sftp-pin-host-key")
}

func TestHostKeyCallbackRejectsCertificate(t *testing.T) {
	f := &Fs{
		opt:      Options{PinHostKey: true},
		hostKeys: map[string][][]byte{},
	}
	var pending *pendingKey
	cb := f.hostKeyCallback(&pending)

	// Wrap a real Ed25519 key in an *ssh.Certificate so the type
	// assertion in the callback fires.
	pub, _, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	sshPub, err := ssh.NewPublicKey(pub)
	require.NoError(t, err)
	cert := &ssh.Certificate{Key: sshPub}

	err = cb("example.com:22", nil, cert)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "certificate")
	assert.Nil(t, pending)
}

func TestHostKeyCallbackPinHostKeyStashes(t *testing.T) {
	mapper := configmap.Simple{}
	f := &Fs{
		opt:      Options{PinHostKey: true},
		hostKeys: map[string][][]byte{},
		m:        mapper,
	}
	keyBytes := []byte("fresh-key-bytes")
	var pending *pendingKey
	cb := f.hostKeyCallback(&pending)
	err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyBytes})
	assert.NoError(t, err)
	require.NotNil(t, pending, "PinHostKey should stash the offered key")
	assert.Equal(t, "ssh-ed25519", pending.algo)
	assert.Equal(t, keyBytes, pending.marshalled)
	// Neither durable nor in-memory state is touched in the callback: the
	// commit happens only after authentication succeeds.
	_, ok := mapper["host_keys"]
	assert.False(t, ok, "callback must not persist; that's commitHostKey's job")
	assert.Empty(t, f.hostKeys["ssh-ed25519"], "callback must not extend in-memory trust set pre-auth")
}

func TestHostKeyCallbackRefusesAtCap(t *testing.T) {
	// Populate host_keys at the cap, then attempt to PinHostKey a brand new
	// algorithm. The callback must refuse to prevent unbounded growth.
	f := &Fs{
		opt:      Options{PinHostKey: true},
		hostKeys: map[string][][]byte{},
	}
	for i := 0; i < maxHostKeys; i++ {
		algo := fmt.Sprintf("test-algo-%d", i)
		f.hostKeys[algo] = [][]byte{{byte(i)}}
	}
	var pending *pendingKey
	cb := f.hostKeyCallback(&pending)
	err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: []byte("new-key")})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "cap")
	// Nothing was stashed for persistence.
	assert.Nil(t, pending)
}

func TestHostKeyCallbackRefusesWhenNotPinning(t *testing.T) {
	// pin_host_key=false AND no entry pinned for this algo -> reject.
	f := &Fs{
		opt:      Options{PinHostKey: false},
		hostKeys: map[string][][]byte{},
	}
	cb := f.hostKeyCallback(nil)
	err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: []byte("x")})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "--sftp-pin-host-key")
}

func TestHostKeyCallbackNilPendingIsValidateOnly(t *testing.T) {
	// Even with pin_host_key set, a callback with no pending slot (the
	// shared validate-only config) must refuse an unpinned key rather
	// than accept it without anywhere to stash it.
	f := &Fs{
		opt:      Options{PinHostKey: true},
		hostKeys: map[string][][]byte{},
	}
	cb := f.hostKeyCallback(nil)
	err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: []byte("x")})
	require.Error(t, err)
}

func TestHostKeyCallbackPinHostKeyRace(t *testing.T) {
	mapper := configmap.Simple{}
	f := &Fs{
		opt:      Options{PinHostKey: true},
		hostKeys: map[string][][]byte{},
		m:        mapper,
	}
	keyBytes := []byte("race-key-bytes")

	// Each concurrent dial gets its own callback and pending key slot.
	var wg sync.WaitGroup
	const N = 16
	pendings := make([]*pendingKey, N)
	wg.Add(N)
	for i := 0; i < N; i++ {
		go func(i int) {
			defer wg.Done()
			cb := f.hostKeyCallback(&pendings[i])
			err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyBytes})
			assert.NoError(t, err)
		}(i)
	}
	wg.Wait()
	for i := 0; i < N; i++ {
		require.NotNil(t, pendings[i], "dial %d should have stashed its own key", i)
		assert.Equal(t, keyBytes, pendings[i].marshalled)
	}
	// No mutation of f.hostKeys pre-auth, even under heavy parallel callbacks.
	assert.Empty(t, f.hostKeys["ssh-ed25519"])
}

func TestHostKeyCallbackPendingKeyIsPerConnection(t *testing.T) {
	// Two dials see different keys (e.g. the first connection failed
	// authentication and a MITM answered the retry). Each callback must
	// stash into its own slot so committing the successful connection's
	// key can never pin the abandoned connection's key.
	mapper := configmap.Simple{}
	f := &Fs{
		name:     "myremote",
		opt:      Options{PinHostKey: true},
		hostKeys: map[string][][]byte{},
		m:        mapper,
	}
	keyA := []byte("key-from-failed-connection")
	keyB := []byte("key-from-successful-connection")

	// Dial A: stashes key A, then authentication fails so it is abandoned.
	var pendingA *pendingKey
	require.NoError(t, f.hostKeyCallback(&pendingA)("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyA}))
	require.NotNil(t, pendingA)

	// Dial B: presents a different key, which must be stashed in B's own
	// slot, not silently accepted because A already stashed one.
	var pendingB *pendingKey
	require.NoError(t, f.hostKeyCallback(&pendingB)("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyB}))
	require.NotNil(t, pendingB)
	assert.Equal(t, keyB, pendingB.marshalled)

	// Only B authenticates, so only B's key is committed and pinned.
	f.commitHostKey(pendingB)
	assert.Equal(t, [][]byte{keyB}, f.hostKeys["ssh-ed25519"])
	assert.Equal(t, "ssh-ed25519 "+base64.StdEncoding.EncodeToString(keyB), mapper["host_keys"])
}

func TestNewFsHostKeysPrecedence(t *testing.T) {
	// Base config pointing at a port nothing listens on, so if NewFs
	// gets as far as connecting it fails with a dial error.
	newConfig := func() configmap.Simple {
		return configmap.Simple{
			"host": "127.0.0.1",
			"port": "1",
			"user": "testuser",
			"pass": obscure.MustObscure("testpass"),
		}
	}
	ctx, ci := fs.AddConfig(context.Background())
	ci.LowLevelRetries = 1

	t.Run("MalformedHostKeysRejectedWhenPinning", func(t *testing.T) {
		m := newConfig()
		m.Set("host_keys", "this is not a valid entry")
		_, err := NewFs(ctx, "test", "", m)
		require.Error(t, err)
		assert.Contains(t, err.Error(), "host_keys")
	})

	t.Run("MalformedHostKeysIgnoredWithKnownHostsFile", func(t *testing.T) {
		// known_hosts_file takes precedence, so a bad host_keys value
		// must not stop the connection: the failure must be the dial
		// error, not a host_keys parse error.
		knownHosts := filepath.Join(t.TempDir(), "known_hosts")
		require.NoError(t, os.WriteFile(knownHosts, nil, 0o600))
		m := newConfig()
		m.Set("known_hosts_file", knownHosts)
		m.Set("host_keys", "this is not a valid entry")
		m.Set("pin_host_key", "true")
		_, err := NewFs(ctx, "test", "", m)
		require.Error(t, err)
		assert.NotContains(t, err.Error(), "host_keys")
	})
}

func TestCommitHostKey(t *testing.T) {
	mapper := configmap.Simple{}
	f := &Fs{
		name:     "myremote",
		hostKeys: map[string][][]byte{},
		m:        mapper,
	}
	key1 := makeTestKeys(t, 1)[0]
	key2 := makeTestRSAKey(t)

	f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: key1, fingerprint: "SHA256:fp1", hostname: "h"})
	f.commitHostKey(&pendingKey{algo: "ssh-rsa", marshalled: key2, fingerprint: "SHA256:fp2", hostname: "h"})

	stored, ok := mapper["host_keys"]
	require.True(t, ok, "commitHostKey must persist to the configmap")
	expected := "ssh-ed25519 " + base64.StdEncoding.EncodeToString(key1) + ",ssh-rsa " + base64.StdEncoding.EncodeToString(key2)
	assert.Equal(t, expected, stored)

	// commitHostKey must also extend the in-memory trust set so the validate
	// path on subsequent connections recognises the freshly-pinned keys.
	assert.Equal(t, [][]byte{key1}, f.hostKeys["ssh-ed25519"])
	assert.Equal(t, [][]byte{key2}, f.hostKeys["ssh-rsa"])

	// Second commit of the same key is a no-op (dedupe).
	f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: key1, fingerprint: "SHA256:fp1", hostname: "h"})
	assert.Equal(t, expected, mapper["host_keys"])
	assert.Equal(t, [][]byte{key1}, f.hostKeys["ssh-ed25519"])
}

func TestCommitHostKeyRefusesToOverwriteMalformed(t *testing.T) {
	// host_keys parsed cleanly at NewFs time, then was hand-edited to
	// something garbage before commit ran. We must not overwrite that
	// value with just our new entry (which would destroy any other valid
	// entries it once contained).
	const garbage = "this is not a valid host_keys value"
	mapper := configmap.Simple{"host_keys": garbage}
	f := &Fs{
		name:     "myremote",
		hostKeys: map[string][][]byte{},
		m:        mapper,
	}
	key := []byte("would-have-been-pinned")
	f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: key, fingerprint: "SHA256:fp", hostname: "h"})

	// Durable storage is preserved as-is.
	assert.Equal(t, garbage, mapper["host_keys"])
	// In-memory trust set is NOT extended — we refuse to pretend we pinned.
	assert.Empty(t, f.hostKeys["ssh-ed25519"])
}

func TestCommitHostKeyConcurrent(t *testing.T) {
	// Concurrent commits from parallel dials must not lose each
	// other's key in the read-modify-write of the stored value.
	mapper := configmap.Simple{}
	f := &Fs{
		name:     "myremote",
		hostKeys: map[string][][]byte{},
		m:        mapper,
	}
	const N = 8
	keys := makeTestKeys(t, N)
	var wg sync.WaitGroup
	wg.Add(N)
	for i := 0; i < N; i++ {
		go func(i int) {
			defer wg.Done()
			f.commitHostKey(&pendingKey{
				algo:        "ssh-ed25519",
				marshalled:  keys[i],
				fingerprint: fmt.Sprintf("SHA256:fp%d", i),
				hostname:    "h",
			})
		}(i)
	}
	wg.Wait()
	assert.Len(t, f.hostKeys["ssh-ed25519"], N)
	stored, err := parseHostKeysField(fs.CommaSepList(strings.Split(mapper["host_keys"], ",")))
	require.NoError(t, err)
	assert.Len(t, stored["ssh-ed25519"], N)
}

func TestCommitHostKeyRefusesAtCap(t *testing.T) {
	// The stored host_keys value may have grown to the cap since the
	// callback checked it (e.g. another connection committed first), so
	// the cap must be re-checked against the re-read value.
	keys := makeTestKeys(t, maxHostKeys+1)
	entries := make(fs.CommaSepList, maxHostKeys)
	for i := range entries {
		entries[i] = "ssh-ed25519 " + base64.StdEncoding.EncodeToString(keys[i])
	}
	mapper := configmap.Simple{"host_keys": entries.String()}
	f := &Fs{
		name:     "myremote",
		hostKeys: map[string][][]byte{},
		m:        mapper,
	}
	f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: keys[maxHostKeys], fingerprint: "SHA256:fp", hostname: "h"})

	// Stored value unchanged and the in-memory set not extended with the new key.
	assert.Equal(t, entries.String(), mapper["host_keys"])
	assert.Empty(t, f.hostKeys["ssh-ed25519"])
}

func TestParseUsage(t *testing.T) {
	for i, test := range []struct {
		sshOutput string
		usage     [3]int64
	}{
		{"Filesystem     1K-blocks     Used Available Use% Mounted on\n/dev/root       91283092 81111888  10154820  89% /", [3]int64{93473886208, 83058573312, 10398535680}},
		{"Filesystem     1K-blocks  Used Available Use% Mounted on\ntmpfs             818256  1636    816620   1% /run", [3]int64{837894144, 1675264, 836218880}},
		{"Filesystem   1024-blocks     Used Available Capacity iused      ifree %iused  Mounted on\n/dev/disk0s2   244277768 94454848 149566920    39%  997820 4293969459    0%   /", [3]int64{250140434432, 96721764352, 153156526080}},
	} {
		gotSpaceTotal, gotSpaceUsed, gotSpaceAvail := parseUsage([]byte(test.sshOutput))
		assert.Equal(t, test.usage, [3]int64{gotSpaceTotal, gotSpaceUsed, gotSpaceAvail}, fmt.Sprintf("Test %d sshOutput = %q", i, test.sshOutput))
	}
}

// internalTestHostKeyPinning exercises the full PinHostKey host-key flow
// against the live SFTP server by mirroring the running Fs's connection
// details into a fresh configmap, then driving NewFs through
// pin/validate/mismatch. The pinned key is written back into the configmap
// so the assertions can read it straight from there.
func (f *Fs) internalTestHostKeyPinning(t *testing.T) {
	ctx := context.Background()

	if len(f.opt.SSH) > 0 {
		t.Skip("host key pinning is bypassed when the ssh option is set")
	}

	// Mirror config from the running Fs into a fresh configmap. f.m.Get
	// returns effective values (including defaults), so the mirrored map is
	// self-contained and NewFs can be called directly with it.
	m := configmap.Simple{}
	for _, opt := range fs.MustFind("sftp").Options {
		// known_hosts_file must not be mirrored as it takes precedence
		// over pin_host_key and would disable the pinning under test.
		if opt.Name == "pin_host_key" || opt.Name == "host_keys" || opt.Name == "known_hosts_file" {
			continue
		}
		v, ok := f.m.Get(opt.Name)
		if !ok || v == "" {
			continue
		}
		m.Set(opt.Name, v)
	}
	m.Set("pin_host_key", "true")

	t.Run("FirstConnectPins", func(t *testing.T) {
		_, err := NewFs(ctx, "pinhostkey", "", m)
		if err != nil && strings.Contains(err.Error(), "SSH certificate") {
			t.Skipf("server presents an SSH certificate; pin_host_key cannot validate: %v", err)
		}
		require.NoError(t, err)
		require.Regexp(t, `^\S+ \S+`, m["host_keys"],
			"PinHostKey should have written host_keys back into the configmap")
	})

	t.Run("SecondConnectValidates", func(t *testing.T) {
		before := m["host_keys"]
		_, err := NewFs(ctx, "pinhostkey", "", m)
		require.NoError(t, err)
		assert.Equal(t, before, m["host_keys"],
			"validate path must not rewrite host_keys")
	})

	t.Run("MismatchRejected", func(t *testing.T) {
		// Substitute a different valid ed25519 key so the mismatch (not the
		// malformed-config) error path fires. This must run in validate-only
		// mode: with pin_host_key still set the negotiation stays open to
		// new algorithms and the server's key would be accepted and pinned
		// on first use rather than mismatching.
		m.Set("pin_host_key", "false")
		_, priv, err := ed25519.GenerateKey(rand.Reader)
		require.NoError(t, err)
		sshPub, err := ssh.NewPublicKey(priv.Public())
		require.NoError(t, err)
		m.Set("host_keys", "ssh-ed25519 "+base64.StdEncoding.EncodeToString(sshPub.Marshal()))

		_, err = NewFs(ctx, "pinhostkey", "", m)
		require.Error(t, err)
		assert.Contains(t, err.Error(), "host key mismatch")
	})
}

// InternalTest dispatches integration-only tests that need a live SFTP
// connection. fstests.Run invokes this after the standard test suite.
func (f *Fs) InternalTest(t *testing.T) {
	t.Run("HostKeyPinning", f.internalTestHostKeyPinning)
}

// Check interface
var _ fstests.InternalTester = (*Fs)(nil)
