e92b18197c
P01 — Load-bearing replacement for v0.8 mTLS transport (R-001). Transport (internal/sshpush/transport.go, REQ-073): - Transport struct with sync.Map connection pool (reuse *ssh.Client per peer). - Exec with context timeout (10s default) + retry (100ms x2 cap 5s max 5 attempts, +/-25% jitter — same backoff as v0.8 transport/retry.go). - ReadFile, WriteFile (atomic heredoc + mv), Close. - sshDialer + sshSession seams for testability. TOFU host-key verification reuses proxmox.TOFUHostKeyCallback. security.Flock for known_hosts. Fan-out (internal/sshpush/fanout.go): - ExecAll, WriteAll with errgroup + SetLimit semaphore (default 8 per I-B-001). Per-peer errors collected, don't cancel the group. Idempotency (internal/sshpush/idempotency.go, C-18): - WriteFileIdempotent: SHA-256 compare via ssh sha256sum; skip if content matches (written=false). Content-addressed idempotency replaces the v0.8 X-Orca-Idempotency-Key header (C-18 capability map). Tests: in-process fake SSH server (ssh.NewServerConn NoClientAuth ed25519) for e2e + interface seams for pure-logic. 93.0% coverage. 20 packages pass. ---ci--- project: orca phase: P01 milestone: v0.9 status: execute ---/ci---
349 lines
9.1 KiB
Go
349 lines
9.1 KiB
Go
package sshpush
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
func TestSha256Hex(t *testing.T) {
|
|
got := sha256Hex([]byte("hello"))
|
|
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
if got != want {
|
|
t.Errorf("sha256Hex = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSplitDir(t *testing.T) {
|
|
dir, base := splitDir("/etc/orca/foo.conf")
|
|
if dir != "/etc/orca" || base != "/etc/orca" {
|
|
t.Errorf("splitDir(/etc/orca/foo.conf) = (%q,%q), want (/etc/orca,/etc/orca)", dir, base)
|
|
}
|
|
dir, base = splitDir("foo.conf")
|
|
if dir != "" || base != "." {
|
|
t.Errorf("splitDir(foo.conf) = (%q,%q), want (\"\",.)", dir, base)
|
|
}
|
|
}
|
|
|
|
func TestRandomToken(t *testing.T) {
|
|
a := randomToken(8)
|
|
b := randomToken(8)
|
|
if a == b {
|
|
t.Error("randomToken returned same value twice")
|
|
}
|
|
if len(a) != 16 { // 8 bytes hex = 16 chars
|
|
t.Errorf("randomToken(8) len = %d, want 16", len(a))
|
|
}
|
|
}
|
|
|
|
// --- WriteFileIdempotent tests via the fake SSH server ---
|
|
|
|
func TestWriteFileIdempotent_WritesWhenFileMissing(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
content := []byte("first content\n")
|
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/a.conf", content, 0o644)
|
|
if err != nil {
|
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
|
}
|
|
if !written {
|
|
t.Error("written=false, want true (file was missing)")
|
|
}
|
|
srv.mu.Lock()
|
|
got := srv.files["/etc/orca/a.conf"]
|
|
srv.mu.Unlock()
|
|
if got != string(content) {
|
|
t.Errorf("remote file = %q, want %q", got, string(content))
|
|
}
|
|
}
|
|
|
|
func TestWriteFileIdempotent_SkipsWhenContentMatches(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
content := []byte("same content\n")
|
|
srv.mu.Lock()
|
|
srv.files["/etc/orca/b.conf"] = string(content)
|
|
srv.mu.Unlock()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/b.conf", content, 0o644)
|
|
if err != nil {
|
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
|
}
|
|
if written {
|
|
t.Error("written=true, want false (content matched)")
|
|
}
|
|
}
|
|
|
|
func TestWriteFileIdempotent_WritesWhenContentDiffers(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
srv.mu.Lock()
|
|
srv.files["/etc/orca/c.conf"] = "old content\n"
|
|
srv.mu.Unlock()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
newContent := []byte("new content\n")
|
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/c.conf", newContent, 0o644)
|
|
if err != nil {
|
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
|
}
|
|
if !written {
|
|
t.Error("written=false, want true (content differed)")
|
|
}
|
|
srv.mu.Lock()
|
|
got := srv.files["/etc/orca/c.conf"]
|
|
srv.mu.Unlock()
|
|
if got != string(newContent) {
|
|
t.Errorf("remote file = %q, want %q", got, string(newContent))
|
|
}
|
|
}
|
|
|
|
func TestWriteFile_DelegatesToIdempotent(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
content := []byte("delegated\n")
|
|
if err := tr.WriteFile(context.Background(), srv.addr(), "/etc/orca/d.conf", content, 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
srv.mu.Lock()
|
|
got := srv.files["/etc/orca/d.conf"]
|
|
srv.mu.Unlock()
|
|
if got != string(content) {
|
|
t.Errorf("remote file = %q, want %q", got, string(content))
|
|
}
|
|
}
|
|
|
|
// --- Pure-logic idempotency tests via mock session (no SSH server) ---
|
|
|
|
// mockHashSession returns the hash of the file matching the sha256sum
|
|
// command's path argument; for write commands (mkdir + cat >), it
|
|
// records the write. This lets us test the idempotency decision logic
|
|
// without a real SSH server.
|
|
type mockHashSession struct {
|
|
files map[string]string
|
|
out []byte
|
|
err error
|
|
cmd string
|
|
writeHook func(cmd string)
|
|
}
|
|
|
|
func (m *mockHashSession) CombinedOutput(cmd string) ([]byte, error) {
|
|
m.cmd = cmd
|
|
c := strings.TrimSpace(cmd)
|
|
if strings.HasPrefix(c, "sha256sum ") {
|
|
rest := strings.TrimSpace(strings.TrimPrefix(c, "sha256sum "))
|
|
rest = strings.TrimSuffix(rest, " 2>/dev/null")
|
|
rest = strings.TrimSpace(rest)
|
|
path := unquote(rest)
|
|
content, ok := m.files[path]
|
|
if !ok {
|
|
return []byte(""), nil
|
|
}
|
|
sum := sha256.Sum256([]byte(content))
|
|
return []byte(hex.EncodeToString(sum[:]) + " " + path + "\n"), nil
|
|
}
|
|
if strings.HasPrefix(c, "mkdir -p ") && strings.Contains(c, "cat >") {
|
|
if m.writeHook != nil {
|
|
m.writeHook(c)
|
|
}
|
|
return nil, nil
|
|
}
|
|
return m.out, m.err
|
|
}
|
|
func (m *mockHashSession) Close() error { return nil }
|
|
|
|
func TestWriteFileIdempotent_MockSkip(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
files := map[string]string{"/x/f": "match"}
|
|
var writes int
|
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
|
return &mockHashSession{
|
|
files: files,
|
|
writeHook: func(string) { writes++ },
|
|
}, nil
|
|
})
|
|
client, err := tr.dial(srv.addr())
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
tr.pool.Store(srv.addr(), client)
|
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("match"), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
|
}
|
|
if written {
|
|
t.Error("written=true, want false (hash matched)")
|
|
}
|
|
if writes != 0 {
|
|
t.Errorf("writes = %d, want 0 (no write on hash match)", writes)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileIdempotent_MockWrite(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
files := map[string]string{"/x/f": "old"}
|
|
var writes int
|
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
|
return &mockHashSession{
|
|
files: files,
|
|
writeHook: func(string) { writes++ },
|
|
}, nil
|
|
})
|
|
client, err := tr.dial(srv.addr())
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
tr.pool.Store(srv.addr(), client)
|
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("new"), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
|
}
|
|
if !written {
|
|
t.Error("written=false, want true (hash differed)")
|
|
}
|
|
if writes != 1 {
|
|
t.Errorf("writes = %d, want 1", writes)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileIdempotent_MockMissingFile(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
files := map[string]string{}
|
|
var writes int
|
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
|
return &mockHashSession{
|
|
files: files,
|
|
writeHook: func(string) { writes++ },
|
|
}, nil
|
|
})
|
|
client, err := tr.dial(srv.addr())
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
tr.pool.Store(srv.addr(), client)
|
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/new", []byte("fresh"), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
|
}
|
|
if !written {
|
|
t.Error("written=false, want true (file missing)")
|
|
}
|
|
if writes != 1 {
|
|
t.Errorf("writes = %d, want 1", writes)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSHA256_ParsesDigest(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
srv.mu.Lock()
|
|
srv.files["/x/h"] = "abc"
|
|
srv.mu.Unlock()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
got, err := tr.remoteSHA256(context.Background(), srv.addr(), "/x/h")
|
|
if err != nil {
|
|
t.Fatalf("remoteSHA256: %v", err)
|
|
}
|
|
want := fmt.Sprintf("%x", sha256.Sum256([]byte("abc")))
|
|
if got != want {
|
|
t.Errorf("remoteSHA256 = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSHA256_MissingFile(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
got, err := t_remoteSHA256_noErr(t, tr, srv.addr(), "/missing")
|
|
if err != nil {
|
|
t.Fatalf("remoteSHA256: %v", err)
|
|
}
|
|
if got != "" {
|
|
t.Errorf("remoteSHA256 = %q, want empty for missing file", got)
|
|
}
|
|
}
|
|
|
|
func t_remoteSHA256_noErr(t *testing.T, tr *Transport, peer, path string) (string, error) {
|
|
t.Helper()
|
|
return tr.remoteSHA256(context.Background(), peer, path)
|
|
}
|
|
|
|
func TestWriteFile_ModeApplied(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
if err := tr.WriteFile(context.Background(), srv.addr(), "/m/f", []byte("mode"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
srv.mu.Lock()
|
|
got := srv.files["/m/f"]
|
|
srv.mu.Unlock()
|
|
if got != "mode" {
|
|
t.Errorf("content = %q, want mode", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileIdempotent_ExecError(t *testing.T) {
|
|
srv := newFakeSSHServer(t)
|
|
defer srv.close()
|
|
tr := realTransport(t, srv)
|
|
defer tr.Close()
|
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
|
return &mockSession{err: fmt.Errorf("%w: boom", ErrPermanent)}, nil
|
|
})
|
|
client, err := tr.dial(srv.addr())
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
tr.pool.Store(srv.addr(), client)
|
|
_, err = tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("z"), 0o644)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParseMode(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in string
|
|
want os.FileMode
|
|
}{
|
|
{"0644", 0o644},
|
|
{"0755", 0o755},
|
|
{"0600", 0o600},
|
|
{"bad", 0o644},
|
|
{"", 0o644},
|
|
{"0", 0o644},
|
|
} {
|
|
got := parseMode(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("parseMode(%q) = %o, want %o", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
var _ = errors.New
|