diff --git a/README.md b/README.md index 05ffaf9..4bb1a87 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,12 @@ Managing multiple servers means juggling different passwords and repeatedly ente ## Key Features 1. Cross-platform SSH/SFTP operations (supports sudo auto-fill). -2. Password management (Keychain / Secret Service / Credential Manager). -3. Host configuration management with per-host SSH keys. -4. Dry-run execution plan preview for humans and agents. -5. Local structured audit trail with safe default redaction. -6. Script execution and command security validation. +2. Direct server-to-server file transfer (`--transfer=: --to=:`), streamed through the local machine without touching local disk. +3. Password management (Keychain / Secret Service / Credential Manager). +4. Host configuration management with per-host SSH keys. +5. Dry-run execution plan preview for humans and agents. +6. Local structured audit trail with safe default redaction. +7. Script execution and command security validation. ## Installation @@ -209,6 +210,9 @@ sshx --password-set=192.168.1.100-root # Use the saved password for sudo auto-fill sshx -h=192.168.1.100 -u=root "sudo df -h" + +# Transfer a file directly from one server to another (streamed, no local copy) +sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log ``` ## Agent / Scripting Mode diff --git a/README_CN.md b/README_CN.md index 88fba6a..d0f57f4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -59,11 +59,12 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\ ## 核心特性 1. 跨平台 SSH/SFTP 操作(支持 sudo 自动填充)。 -2. 密码管理(Keychain / Secret Service / Credential Manager)。 -3. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。 -4. 面向人和 agent 的 dry-run 执行计划预览。 -5. 本地结构化审计日志,并默认做安全脱敏。 -6. 脚本执行和命令安全验证。 +2. 服务器到服务器直接文件传输(`--transfer=: --to=:`),数据经本机中转流式传输,不落本地磁盘。 +3. 密码管理(Keychain / Secret Service / Credential Manager)。 +4. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。 +5. 面向人和 agent 的 dry-run 执行计划预览。 +6. 本地结构化审计日志,并默认做安全脱敏。 +7. 脚本执行和命令安全验证。 ## 安装 @@ -210,6 +211,9 @@ sshx -h=192.168.1.100 -u=root "sudo df -h" # 一次性测试所有已配置的主机(每台主机 10 秒拨号超时),并在报告中标注认证方式 sshx --host-test-all + +# 服务器到服务器直接传输文件(流式中转,不落本地磁盘) +sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log ``` ## Agent / 脚本模式 diff --git a/internal/app/app.go b/internal/app/app.go index d478b6a..0f2ac1d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -98,6 +98,14 @@ func Run(args []string) (err error) { return nil } + // Handle server-to-server transfer mode + if config.Mode == "transfer" { + if transferErr := HandleTransfer(config); transferErr != nil { + return fmt.Errorf("transfer failed: %w", transferErr) + } + return nil + } + // Validate flags that only apply to command execution. if config.Mode == "ssh" { if config.Timeout < 0 { diff --git a/internal/app/audit.go b/internal/app/audit.go index 55980a0..99801a4 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -59,6 +59,9 @@ type auditEvent struct { LocalPath string `json:"local_path,omitempty"` RemotePath string `json:"remote_path,omitempty"` + TransferSource string `json:"transfer_source,omitempty"` + TransferDest string `json:"transfer_destination,omitempty"` + UseKeyAuth bool `json:"use_key_auth"` KeyPath string `json:"key_path,omitempty"` PasswordProvided bool `json:"password_provided"` @@ -230,6 +233,10 @@ func (r *auditRecorder) refresh(config *sshclient.Config) { r.event.SftpAction = config.SftpAction r.event.LocalPath = config.LocalPath r.event.RemotePath = config.RemotePath + if config.Mode == "transfer" { + r.event.TransferSource = formatTransferEndpoint(config.TransferSrcHost, config.TransferSrcPath) + r.event.TransferDest = formatTransferEndpoint(config.TransferDstHost, config.TransferDstPath) + } r.event.UseKeyAuth = config.UseKeyAuth r.event.KeyPath = config.KeyPath r.event.PasswordProvided = config.Password != "" @@ -317,11 +324,20 @@ func auditAction(config *sshclient.Config) string { return config.PasswordAction case "host": return config.HostAction + case "transfer": + return "transfer" default: return "" } } +func formatTransferEndpoint(host, path string) string { + if host == "" && path == "" { + return "" + } + return host + ":" + path +} + func auditWouldReadSecret(config *sshclient.Config) bool { switch config.Mode { case "ssh": @@ -352,6 +368,8 @@ func auditWouldMutateRemote(config *sshclient.Config) bool { return config.Command != "" case "sftp": return config.SftpAction == "upload" || config.SftpAction == "mkdir" || config.SftpAction == "remove" + case "transfer": + return true case "host": return config.HostAction == "test" || config.HostAction == "test-all" default: diff --git a/internal/app/config.go b/internal/app/config.go index a7843ae..b3dcbaa 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -32,6 +32,16 @@ func parseTimeout(value string) (time.Duration, error) { return 0, fmt.Errorf("invalid timeout %q (use e.g. 30s, 2m, or 30)", value) } +// splitHostPath splits a "host:path" transfer spec at the first colon. +// If there is no colon, the whole value is treated as a host with an empty path. +func splitHostPath(spec string) (host, path string) { + parts := strings.SplitN(spec, ":", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + return spec, "" +} + // ParseArgs parses command-line arguments and returns a Config. func ParseArgs(args []string) *sshclient.Config { config := &sshclient.Config{ @@ -152,11 +162,16 @@ func ParseArgs(args []string) *sshclient.Config { config.Mode = "sftp" config.SftpAction = "download" config.RemotePath = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--transfer="): + config.Mode = "transfer" + config.TransferSrcHost, config.TransferSrcPath = splitHostPath(strings.SplitN(arg, "=", 2)[1]) case strings.HasPrefix(arg, "--to="): - switch config.SftpAction { - case "upload": + switch { + case config.Mode == "transfer": + config.TransferDstHost, config.TransferDstPath = splitHostPath(strings.SplitN(arg, "=", 2)[1]) + case config.SftpAction == "upload": config.RemotePath = strings.SplitN(arg, "=", 2)[1] - case "download": + case config.SftpAction == "download": config.LocalPath = strings.SplitN(arg, "=", 2)[1] } case strings.HasPrefix(arg, "--list="), strings.HasPrefix(arg, "--ls="): diff --git a/internal/app/dryrun.go b/internal/app/dryrun.go index 8adc2d4..27a93ea 100644 --- a/internal/app/dryrun.go +++ b/internal/app/dryrun.go @@ -33,6 +33,9 @@ type dryRunPlan struct { LocalPath string `json:"local_path,omitempty"` RemotePath string `json:"remote_path,omitempty"` + TransferSource string `json:"transfer_source,omitempty"` + TransferDest string `json:"transfer_destination,omitempty"` + UseKeyAuth bool `json:"use_key_auth"` KeyPath string `json:"key_path,omitempty"` PasswordProvided bool `json:"password_provided"` @@ -137,6 +140,10 @@ func fillDryRunAction(config *sshclient.Config, plan *dryRunPlan) { plan.Action = config.PasswordAction case "host": plan.Action = config.HostAction + case "transfer": + plan.Action = "transfer" + plan.TransferSource = formatTransferEndpoint(config.TransferSrcHost, config.TransferSrcPath) + plan.TransferDest = formatTransferEndpoint(config.TransferDstHost, config.TransferDstPath) } } @@ -277,6 +284,26 @@ func fillDryRunSudo(config *sshclient.Config, plan *dryRunPlan) { } func fillDryRunValidation(config *sshclient.Config, plan *dryRunPlan) { + if config.Mode == "transfer" { + if config.TransferSrcHost == "" || config.TransferSrcPath == "" { + plan.ConfigCheck = dryRunStatus{ + Status: "error", + ErrorKind: "config", + Message: "source must be specified as --transfer=:", + } + plan.Valid = false + return + } + if config.TransferDstHost == "" || config.TransferDstPath == "" { + plan.ConfigCheck = dryRunStatus{ + Status: "error", + ErrorKind: "config", + Message: "destination must be specified as --to=:", + } + plan.Valid = false + } + return + } if config.Mode == "ssh" { if config.Timeout < 0 { plan.ConfigCheck = dryRunStatus{ @@ -329,6 +356,9 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) { case "sftp": plan.WouldConnect = canProceed plan.WouldMutateRemote = plan.WouldConnect && (config.SftpAction == "upload" || config.SftpAction == "mkdir" || config.SftpAction == "remove") + case "transfer": + plan.WouldConnect = canProceed + plan.WouldMutateRemote = canProceed case "password": plan.WouldReadSecret = canProceed && (config.PasswordAction == "get" || config.PasswordAction == "check" || config.PasswordAction == "delete" || config.PasswordAction == "list") plan.WouldWriteLocalState = canProceed && (config.PasswordAction == "set" || config.PasswordAction == "delete") @@ -347,7 +377,7 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) { } func modeUsesSSHConnection(config *sshclient.Config) bool { - if config.Mode == "ssh" || config.Mode == "sftp" { + if config.Mode == "ssh" || config.Mode == "sftp" || config.Mode == "transfer" { return true } return config.Mode == "host" && (config.HostAction == "test" || config.HostAction == "test-all") @@ -375,7 +405,7 @@ func printDryRunPlan(plan dryRunPlan) { } fmt.Println() } - if plan.User != "" || plan.Port != "" { + if plan.Mode != "transfer" && (plan.User != "" || plan.Port != "") { fmt.Printf("Target: %s@%s:%s\n", firstNonEmpty(plan.User, "-"), firstNonEmpty(plan.HostResolved, "-"), firstNonEmpty(plan.Port, "-")) } if plan.Command != "" { @@ -384,6 +414,9 @@ func printDryRunPlan(plan dryRunPlan) { if plan.SftpAction != "" { fmt.Printf("SFTP: %s local=%q remote=%q\n", plan.SftpAction, plan.LocalPath, plan.RemotePath) } + if plan.TransferSource != "" || plan.TransferDest != "" { + fmt.Printf("Transfer: %s → %s\n", plan.TransferSource, plan.TransferDest) + } fmt.Printf("Config check: %s\n", statusText(plan.ConfigCheck)) fmt.Printf("Safety check: %s\n", statusText(plan.SafetyCheck)) fmt.Printf("Uses sudo: %t", plan.UsesSudo) diff --git a/internal/app/transfer.go b/internal/app/transfer.go new file mode 100644 index 0000000..f5bb7a6 --- /dev/null +++ b/internal/app/transfer.go @@ -0,0 +1,89 @@ +package app + +import ( + "fmt" + + "github.com/talkincode/sshx/internal/sshclient" + "github.com/talkincode/sshx/pkg/logger" +) + +// HandleTransfer performs a direct server-to-server file transfer. It opens +// SSH connections to both the source and destination hosts and streams data +// between them through the local machine without touching local disk. +func HandleTransfer(config *sshclient.Config) (err error) { + lg := logger.GetLogger() + + if config.TransferSrcHost == "" || config.TransferSrcPath == "" { + return fmt.Errorf("source must be specified as --transfer=:") + } + if config.TransferDstHost == "" || config.TransferDstPath == "" { + return fmt.Errorf("destination must be specified as --to=:") + } + + srcClient, err := connectTransferEndpoint(config, config.TransferSrcHost, "source") + if err != nil { + return err + } + defer func() { + if closeErr := srcClient.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() + + dstClient, err := connectTransferEndpoint(config, config.TransferDstHost, "destination") + if err != nil { + return err + } + defer func() { + if closeErr := dstClient.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() + + lg.Info("Transfer: %s:%s → %s:%s", + config.TransferSrcHost, config.TransferSrcPath, + config.TransferDstHost, config.TransferDstPath) + + if err = srcClient.TransferTo(dstClient, config.TransferSrcPath, config.TransferDstPath); err != nil { + return err + } + + lg.Success("Transfer completed: %s:%s → %s:%s", + config.TransferSrcHost, config.TransferSrcPath, + config.TransferDstHost, config.TransferDstPath) + return nil +} + +// connectTransferEndpoint builds a per-endpoint config derived from the base +// invocation, resolves the host from settings when applicable, and returns a +// connected SSH client. +func connectTransferEndpoint(base *sshclient.Config, host, role string) (*sshclient.SSHClient, error) { + endpoint := &sshclient.Config{ + Host: host, + Port: base.Port, + User: base.User, + Password: base.Password, + KeyPath: base.KeyPath, + UseKeyAuth: base.UseKeyAuth, + SudoKey: base.SudoKey, + DialTimeout: base.DialTimeout, + AcceptUnknownHost: base.AcceptUnknownHost, + AllowInsecureHostKey: base.AllowInsecureHostKey, + KnownHostsPath: base.KnownHostsPath, + } + + if !isIPAddress(endpoint.Host) { + if resolveErr := resolveHostFromSettings(endpoint); resolveErr != nil { + logger.GetLogger().Info("Note: Could not find %s host '%s' in settings, using as hostname directly", role, endpoint.Host) + } + } + + client, err := sshclient.NewSSHClient(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to create SSH client for %s host %s: %w", role, host, err) + } + if err := client.ConnectDirect(); err != nil { + return nil, fmt.Errorf("failed to connect to %s host %s: %w", role, host, err) + } + return client, nil +} diff --git a/internal/app/transfer_test.go b/internal/app/transfer_test.go new file mode 100644 index 0000000..a39e7da --- /dev/null +++ b/internal/app/transfer_test.go @@ -0,0 +1,120 @@ +package app + +import ( + "strings" + "testing" + + "github.com/talkincode/sshx/internal/sshclient" +) + +func TestParseArgs_TransferMode(t *testing.T) { + args := []string{"sshx", "--transfer=hostA:/var/log/app.log", "--to=hostB:/backup/app.log"} + config := ParseArgs(args) + + if config.Mode != "transfer" { + t.Errorf("Expected mode 'transfer', got %s", config.Mode) + } + if config.TransferSrcHost != "hostA" { + t.Errorf("Expected source host 'hostA', got %s", config.TransferSrcHost) + } + if config.TransferSrcPath != "/var/log/app.log" { + t.Errorf("Expected source path '/var/log/app.log', got %s", config.TransferSrcPath) + } + if config.TransferDstHost != "hostB" { + t.Errorf("Expected destination host 'hostB', got %s", config.TransferDstHost) + } + if config.TransferDstPath != "/backup/app.log" { + t.Errorf("Expected destination path '/backup/app.log', got %s", config.TransferDstPath) + } +} + +func TestParseArgs_TransferToDoesNotAffectSftp(t *testing.T) { + args := []string{"sshx", "-h=192.168.1.100", "--upload=local.txt", "--to=/tmp/remote.txt"} + config := ParseArgs(args) + + if config.Mode != "sftp" { + t.Errorf("Expected mode 'sftp', got %s", config.Mode) + } + if config.RemotePath != "/tmp/remote.txt" { + t.Errorf("Expected remote path '/tmp/remote.txt', got %s", config.RemotePath) + } + if config.TransferDstHost != "" || config.TransferDstPath != "" { + t.Errorf("Expected transfer destination to be empty, got %s:%s", config.TransferDstHost, config.TransferDstPath) + } +} + +func TestSplitHostPath(t *testing.T) { + tests := []struct { + spec string + wantHost string + wantPath string + }{ + {"hostA:/var/log", "hostA", "/var/log"}, + {"192.168.1.100:/tmp/file.txt", "192.168.1.100", "/tmp/file.txt"}, + {"hostA:relative/path", "hostA", "relative/path"}, + {"hostA:", "hostA", ""}, + {"hostA", "hostA", ""}, + {"", "", ""}, + } + for _, tt := range tests { + host, path := splitHostPath(tt.spec) + if host != tt.wantHost || path != tt.wantPath { + t.Errorf("splitHostPath(%q) = (%q, %q), want (%q, %q)", tt.spec, host, path, tt.wantHost, tt.wantPath) + } + } +} + +func TestHandleTransfer_MissingSource(t *testing.T) { + config := &sshclient.Config{Mode: "transfer", TransferDstHost: "hostB", TransferDstPath: "/tmp/x"} + err := HandleTransfer(config) + if err == nil || !strings.Contains(err.Error(), "--transfer=:") { + t.Errorf("Expected missing source error, got %v", err) + } +} + +func TestHandleTransfer_MissingDestination(t *testing.T) { + config := &sshclient.Config{Mode: "transfer", TransferSrcHost: "hostA", TransferSrcPath: "/tmp/x"} + err := HandleTransfer(config) + if err == nil || !strings.Contains(err.Error(), "--to=:") { + t.Errorf("Expected missing destination error, got %v", err) + } +} + +func TestBuildDryRunPlan_Transfer(t *testing.T) { + config := ParseArgs([]string{"sshx", "--transfer=10.0.0.1:/data/file", "--to=10.0.0.2:/data/file", "--dry-run"}) + plan := buildDryRunPlan(config) + + if plan.Mode != "transfer" { + t.Errorf("Expected mode 'transfer', got %s", plan.Mode) + } + if plan.Action != "transfer" { + t.Errorf("Expected action 'transfer', got %s", plan.Action) + } + if plan.TransferSource != "10.0.0.1:/data/file" { + t.Errorf("Expected transfer source '10.0.0.1:/data/file', got %s", plan.TransferSource) + } + if plan.TransferDest != "10.0.0.2:/data/file" { + t.Errorf("Expected transfer destination '10.0.0.2:/data/file', got %s", plan.TransferDest) + } + if !plan.Valid { + t.Errorf("Expected plan to be valid") + } + if !plan.WouldConnect || !plan.WouldMutateRemote { + t.Errorf("Expected WouldConnect and WouldMutateRemote to be true, got %t/%t", plan.WouldConnect, plan.WouldMutateRemote) + } +} + +func TestBuildDryRunPlan_TransferMissingDestination(t *testing.T) { + config := ParseArgs([]string{"sshx", "--transfer=10.0.0.1:/data/file", "--dry-run"}) + plan := buildDryRunPlan(config) + + if plan.Valid { + t.Errorf("Expected plan to be invalid without destination") + } + if plan.ConfigCheck.Status != "error" || plan.ConfigCheck.ErrorKind != "config" { + t.Errorf("Expected config error, got %+v", plan.ConfigCheck) + } + if plan.WouldConnect { + t.Errorf("Expected WouldConnect to be false for invalid plan") + } +} diff --git a/internal/app/usage.go b/internal/app/usage.go index 9965bbd..a353e3f 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -14,6 +14,7 @@ Usage: sshx -h= [options] # SSH mode sshx -h= [options] --upload= # SFTP upload sshx -h= [options] --download= # SFTP download + sshx --transfer=: --to=: # Server-to-server transfer sshx --password-set=[:] # Set password in keyring sshx --password-get= # Get password from keyring sshx --password-delete= # Delete password from keyring @@ -114,6 +115,15 @@ SFTP Options: --mkdir= Create remote directory --rm= Remove remote file or directory +Server-to-Server Transfer: + --transfer=: --to=: + + Streams files directly from one server to another through the local + machine (nothing is written to local disk). Supports single files and + recursive directory transfers, and preserves file permission bits. + Both hosts can be configured host names (from ~/.sshx/settings.json) + or IP addresses, each using its own SSH key/user/port from settings. + Password Management (Cross-Platform): --password-set=[:] Set password in system keyring If password omitted, will prompt @@ -212,6 +222,22 @@ SFTP Examples: sshx -h=192.168.1.100 --upload=$file --to=/backup/$file done +Server-to-Server Transfer Examples: + # Transfer a file directly between two servers (by IP) + sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log + + # Transfer between configured hosts (from settings.json) + sshx --transfer=prod-web:/etc/nginx/nginx.conf --to=staging-web:/etc/nginx/nginx.conf + + # Transfer a whole directory recursively + sshx --transfer=prod-db:/var/backups --to=backup-server:/mnt/archive/db + + # If the destination is an existing directory, the source is placed inside it + sshx --transfer=prod-web:/var/log/app.log --to=log-server:/var/logs/ + + # Preview the transfer plan without connecting + sshx --transfer=prod-web:/data --to=prod-db:/data --dry-run + Password Management Examples: # Set default sudo password (interactive prompt) sshx --password-set=master diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 086df61..fd74fad 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -85,6 +85,12 @@ type Config struct { LocalPath string RemotePath string + // Server-to-server transfer fields (Mode == "transfer"). + TransferSrcHost string + TransferSrcPath string + TransferDstHost string + TransferDstPath string + PasswordAction string PasswordKey string PasswordValue string diff --git a/internal/sshclient/transfer.go b/internal/sshclient/transfer.go new file mode 100644 index 0000000..f1ca64a --- /dev/null +++ b/internal/sshclient/transfer.go @@ -0,0 +1,119 @@ +package sshclient + +import ( + "fmt" + "io" + "os" + "path" + + "github.com/pkg/sftp" + "github.com/talkincode/sshx/pkg/errutil" + "github.com/talkincode/sshx/pkg/logger" +) + +// TransferTo streams files from this client's remote host directly to the +// destination client's remote host over SFTP, relaying the data through the +// local machine without writing it to local disk. It supports single files +// and recursive directory transfers. +func (c *SSHClient) TransferTo(dst *SSHClient, srcPath, dstPath string) (err error) { + if srcPath == "" || dstPath == "" { + return fmt.Errorf("both source and destination paths are required") + } + + srcSftp, err := sftp.NewClient(c.client) + if err != nil { + return fmt.Errorf("failed to create SFTP client on source host: %w", err) + } + defer errutil.HandleCloseError(&err, srcSftp) + + dstSftp, err := sftp.NewClient(dst.client) + if err != nil { + return fmt.Errorf("failed to create SFTP client on destination host: %w", err) + } + defer errutil.HandleCloseError(&err, dstSftp) + + srcStat, err := srcSftp.Stat(srcPath) + if err != nil { + return fmt.Errorf("failed to stat source path %s: %w", srcPath, err) + } + + // If the destination is an existing directory, place the source inside it. + if dstStat, statErr := dstSftp.Stat(dstPath); statErr == nil && dstStat.IsDir() { + dstPath = remotePathJoin(dstPath, path.Base(srcPath)) + } + + if srcStat.IsDir() { + return transferDirectory(srcSftp, dstSftp, srcPath, dstPath) + } + return transferFile(srcSftp, dstSftp, srcPath, dstPath, srcStat.Mode()) +} + +// transferDirectory recursively copies a remote directory tree from the +// source SFTP session to the destination SFTP session. +func transferDirectory(srcSftp, dstSftp *sftp.Client, srcDir, dstDir string) error { + if err := dstSftp.MkdirAll(dstDir); err != nil { + return fmt.Errorf("failed to create destination directory %s: %w", dstDir, err) + } + + entries, err := srcSftp.ReadDir(srcDir) + if err != nil { + return fmt.Errorf("failed to read source directory %s: %w", srcDir, err) + } + + for _, entry := range entries { + srcEntry := remotePathJoin(srcDir, entry.Name()) + dstEntry := remotePathJoin(dstDir, entry.Name()) + + switch { + case entry.IsDir(): + if err := transferDirectory(srcSftp, dstSftp, srcEntry, dstEntry); err != nil { + return err + } + case entry.Mode().IsRegular(): + if err := transferFile(srcSftp, dstSftp, srcEntry, dstEntry, entry.Mode()); err != nil { + return err + } + default: + logger.GetLogger().Warning("Skipping non-regular file: %s", srcEntry) + } + } + return nil +} + +// transferFile streams a single remote file from the source SFTP session to +// the destination SFTP session and preserves the file permission bits. +func transferFile(srcSftp, dstSftp *sftp.Client, srcPath, dstPath string, mode os.FileMode) (err error) { + lg := logger.GetLogger() + + srcFile, err := srcSftp.Open(srcPath) + if err != nil { + return fmt.Errorf("failed to open source file %s: %w", srcPath, err) + } + defer errutil.HandleCloseError(&err, srcFile) + + if dir := path.Dir(dstPath); dir != "." && dir != "/" { + if mkErr := dstSftp.MkdirAll(dir); mkErr != nil { + return fmt.Errorf("failed to create destination directory %s: %w", dir, mkErr) + } + } + + dstFile, err := dstSftp.Create(dstPath) + if err != nil { + return fmt.Errorf("failed to create destination file %s: %w", dstPath, err) + } + defer errutil.HandleCloseError(&err, dstFile) + + lg.Info("Transferring: %s → %s", srcPath, dstPath) + + written, err := io.Copy(dstFile, srcFile) + if err != nil { + return fmt.Errorf("failed to transfer file %s: %w", srcPath, err) + } + + if chmodErr := dstSftp.Chmod(dstPath, mode.Perm()); chmodErr != nil { + lg.Warning("failed to preserve permissions on %s: %v", dstPath, chmodErr) + } + + lg.Success("Transferred %d bytes: %s", written, dstPath) + return nil +}