diff --git a/Makefile b/Makefile index f3a4081..4c5ef70 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=syncserver -VERSION?=1.0.36 +VERSION?=1.0.37 GO?=go LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/cmd/server/main.go b/cmd/server/main.go index 8509cd9..51ca605 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,7 +20,7 @@ import ( "github.com/syncserver/internal/syncengine" ) -var version = "1.0.36" +var version = "1.0.37" func main() { cfgPath := flag.String("config", "", "Path to config.yaml") diff --git a/internal/syncengine/rsync_runner.go b/internal/syncengine/rsync_runner.go index bf43dad..d3eb908 100644 --- a/internal/syncengine/rsync_runner.go +++ b/internal/syncengine/rsync_runner.go @@ -40,16 +40,19 @@ func NewRsyncRunner(sshDir, privKey string) *RsyncRunner { } func (r *RsyncRunner) Run(ctx context.Context, pair *SyncPairConfig, onLine func(stream string, line string)) (*RsyncResult, error) { - args := r.buildArgs(pair) + cmd := r.buildRsyncCmd(pair) + return r.runCmd(ctx, cmd, onLine) +} - cmd := exec.CommandContext(ctx, "rsync", args...) +func (r *RsyncRunner) buildRsyncCmd(pair *SyncPairConfig) *exec.Cmd { + args := r.buildArgs(pair) + cmd := exec.CommandContext(context.Background(), "rsync", args...) if r.privKey != "" { sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s", r.privKey, strings.TrimRight(r.sshDir, "/")+"/known_hosts") - cmd.Args = append([]string{"rsync", "-e", sshCmd}, args[1:]...) + cmd.Args = append([]string{"rsync", "-e", sshCmd}, args...) } - - return r.runCmd(ctx, cmd, onLine) + return cmd } func (r *RsyncRunner) buildArgs(pair *SyncPairConfig) []string { @@ -93,17 +96,39 @@ func (r *RsyncRunner) RunRemote(ctx context.Context, pair *SyncPairConfig, src * destKey = filepath.Join(r.sshDir, "id_ed25519") } - destUserHost := fmt.Sprintf("%s@%s", dst.SSHUser, dst.Host) + srcUserHost := fmt.Sprintf("%s@%s", src.SSHUser, src.Host) args := r.buildArgs(pair) innerSSH := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s", destKey, filepath.Join(r.sshDir, "known_hosts")) rsyncFlags := strings.Join(args[:len(args)-2], " ") - sourcePath := args[len(args)-2] - destPath := args[len(args)-1] + + var sourcePath, destPath string + if pair.Direction == "pull" { + sourcePath = args[len(args)-1] + destPath = args[len(args)-2] + } else { + sourcePath = args[len(args)-2] + destPath = args[len(args)-1] + } + + var remoteSrc, remoteDst string + if pair.Direction == "pull" { + remoteSrc = sourcePath + remoteDst = destPath + if !strings.Contains(destPath, "@") { + remoteDst = fmt.Sprintf("%s@%s:%s", dst.SSHUser, dst.Host, destPath) + } + } else { + remoteDst = destPath + if !strings.Contains(destPath, "@") { + remoteDst = fmt.Sprintf("%s@%s:%s", dst.SSHUser, dst.Host, destPath) + } + remoteSrc = fmt.Sprintf("%s:%s", srcUserHost, sourcePath) + } remoteCmd := fmt.Sprintf("rsync %s -e %q %s %s", - rsyncFlags, innerSSH, sourcePath, destUserHost+":"+destPath) + rsyncFlags, innerSSH, remoteSrc, remoteDst) sshArgs := []string{ "-i", src.PrivKey, diff --git a/internal/syncengine/rsync_runner_test.go b/internal/syncengine/rsync_runner_test.go new file mode 100644 index 0000000..6b00221 --- /dev/null +++ b/internal/syncengine/rsync_runner_test.go @@ -0,0 +1,259 @@ +package syncengine + +import ( + "strings" + "testing" +) + +type remoteCmdTest struct { + srcHost string + srcUser string + srcPath string + dstHost string + dstUser string + dstPath string + direction string +} + +func (tc remoteCmdTest) build() (srcArg, dstArg string) { + srcUserHost := tc.srcUser + "@" + tc.srcHost + dstUserHost := tc.dstUser + "@" + tc.dstHost + + runner := NewRsyncRunner("/tmp/ssh", "") + args := runner.buildArgs(&SyncPairConfig{ + Source: tc.srcPath, + Dest: tc.dstPath, + Direction: tc.direction, + }) + + var sourcePath, destPath string + if tc.direction == "pull" { + sourcePath = args[len(args)-1] + destPath = args[len(args)-2] + } else { + sourcePath = args[len(args)-2] + destPath = args[len(args)-1] + } + + var remoteSrc, remoteDst string + if tc.direction == "pull" { + remoteSrc = sourcePath + remoteDst = destPath + if !strings.Contains(destPath, "@") { + remoteDst = dstUserHost + ":" + destPath + } + } else { + remoteDst = destPath + if !strings.Contains(destPath, "@") { + remoteDst = dstUserHost + ":" + destPath + } + remoteSrc = srcUserHost + ":" + sourcePath + } + return remoteSrc, remoteDst +} + +func TestBuildArgs_Push(t *testing.T) { + runner := NewRsyncRunner("/tmp/ssh", "") + pair := &SyncPairConfig{ + Source: "/local/src", + Dest: "admin@10.5.0.144:/remote/dst", + Direction: "push", + RsyncFlags: "-aP", + ExcludePatterns: []string{}, + } + args := runner.buildArgs(pair) + if args[0] != "-aP" { + t.Errorf("first flag = %q, want %q", args[0], "-aP") + } + if args[len(args)-2] != "/local/src" { + t.Errorf("source = %q, want %q", args[len(args)-2], "/local/src") + } + if args[len(args)-1] != "admin@10.5.0.144:/remote/dst" { + t.Errorf("dest = %q, want %q", args[len(args)-1], "admin@10.5.0.144:/remote/dst") + } +} + +func TestBuildArgs_Pull(t *testing.T) { + runner := NewRsyncRunner("/tmp/ssh", "") + pair := &SyncPairConfig{ + Source: "admin@10.5.0.144:/remote/src", + Dest: "/local/dst", + Direction: "pull", + RsyncFlags: "-aP", + ExcludePatterns: []string{}, + } + args := runner.buildArgs(pair) + if args[len(args)-2] != "/local/dst" { + t.Errorf("pull: second-to-last (dest) = %q, want %q", args[len(args)-2], "/local/dst") + } + if args[len(args)-1] != "admin@10.5.0.144:/remote/src" { + t.Errorf("pull: last (source) = %q, want %q", args[len(args)-1], "admin@10.5.0.144:/remote/src") + } +} + +func TestBuildArgs_Mirror(t *testing.T) { + runner := NewRsyncRunner("/tmp/ssh", "") + pair := &SyncPairConfig{ + Source: "/local/src", + Dest: "admin@10.5.0.144:/remote/dst", + Direction: "mirror", + RsyncFlags: "-aP", + ExcludePatterns: []string{}, + } + args := runner.buildArgs(pair) + foundDelete := false + for _, a := range args { + if a == "--delete" { + foundDelete = true + break + } + } + if !foundDelete { + t.Errorf("mirror args = %v, want --delete present", args) + } +} + +func TestBuildArgs_MultiTokenFlags(t *testing.T) { + runner := NewRsyncRunner("/tmp/ssh", "") + pair := &SyncPairConfig{ + Source: "/local/src", + Dest: "admin@10.5.0.144:/remote/dst", + Direction: "push", + RsyncFlags: "-aP --partial", + ExcludePatterns: []string{}, + } + args := runner.buildArgs(pair) + if args[0] != "-aP" { + t.Errorf("first flag = %q, want %q", args[0], "-aP") + } + if args[1] != "--partial" { + t.Errorf("second flag = %q, want %q", args[1], "--partial") + } +} + +func TestBuildArgs_ExcludePatterns(t *testing.T) { + runner := NewRsyncRunner("/tmp/ssh", "") + pair := &SyncPairConfig{ + Source: "/local/src", + Dest: "admin@10.5.0.144:/remote/dst", + Direction: "push", + RsyncFlags: "-aP", + ExcludePatterns: []string{"*.tmp", ".DS_Store"}, + } + args := runner.buildArgs(pair) + var excludes []string + for _, a := range args { + if strings.HasPrefix(a, "--exclude=") { + excludes = append(excludes, a) + } + } + if len(excludes) != 2 { + t.Errorf("excludes = %v, want 2 exclude entries", excludes) + } + if excludes[0] != "--exclude=*.tmp" { + t.Errorf("exclude[0] = %q, want %q", excludes[0], "--exclude=*.tmp") + } +} + +func TestRunRemote_PushDestNoDoublePrefix(t *testing.T) { + tc := remoteCmdTest{ + srcHost: "10.5.1.10", + srcUser: "root", + srcPath: "/share/homes/admin/media", + dstHost: "10.5.0.144", + dstUser: "admin", + dstPath: "admin@10.5.0.144:/share/media/peliculas", + direction: "push", + } + _, dstArg := tc.build() + + if countOccurrences(dstArg, "@") > 1 { + t.Errorf("push dest %q has double SSH spec", dstArg) + } + if dstArg != "admin@10.5.0.144:/share/media/peliculas" { + t.Errorf("push dest = %q, want 'admin@10.5.0.144:/share/media/peliculas'", dstArg) + } +} + +func TestRunRemote_PushSrcCorrectlyPrefixed(t *testing.T) { + tc := remoteCmdTest{ + srcHost: "10.5.1.10", + srcUser: "root", + srcPath: "/share/homes/admin/media", + dstHost: "10.5.0.144", + dstUser: "admin", + dstPath: "/share/media/peliculas", + direction: "push", + } + srcArg, dstArg := tc.build() + + if !strings.HasPrefix(srcArg, "root@10.5.1.10:/") { + t.Errorf("push src = %q, want prefix 'root@10.5.1.10:/'", srcArg) + } + if !strings.HasPrefix(dstArg, "admin@10.5.0.144:/") { + t.Errorf("push dst = %q, want prefix 'admin@10.5.0.144:/'", dstArg) + } +} + +func TestRunRemote_PullSrcNotPrefixed(t *testing.T) { + tc := remoteCmdTest{ + srcHost: "10.5.0.144", + srcUser: "admin", + srcPath: "admin@10.5.0.144:/share/media/peliculas", + dstHost: "10.5.1.10", + dstUser: "root", + dstPath: "/share/data", + direction: "pull", + } + srcArg, _ := tc.build() + + if countOccurrences(srcArg, "@") > 1 { + t.Errorf("pull src %q has double SSH spec", srcArg) + } + if srcArg != "admin@10.5.0.144:/share/media/peliculas" { + t.Errorf("pull src = %q, want 'admin@10.5.0.144:/share/media/peliculas'", srcArg) + } +} + +func countOccurrences(s, substr string) int { + return strings.Count(s, substr) +} + +func TestRun_FlagsPreservedWithPrivKey(t *testing.T) { + runner := NewRsyncRunner("/tmp/ssh", "/tmp/ssh/id_ed25519") + pair := &SyncPairConfig{ + Source: "/local/src", + Dest: "admin@10.5.0.144:/remote/dst", + Direction: "push", + RsyncFlags: "-aP --partial", + ExcludePatterns: []string{}, + } + + cmd := runner.buildRsyncCmd(pair) + + if cmd.Args[0] != "rsync" { + t.Errorf("cmd.Args[0] = %q, want 'rsync'", cmd.Args[0]) + } + if cmd.Args[1] != "-e" { + t.Errorf("cmd.Args[1] = %q, want '-e' (the -e flag for ssh)", cmd.Args[1]) + } + if !strings.Contains(cmd.Args[2], "ssh -i") { + t.Errorf("cmd.Args[2] = %q, want ssh -i ...", cmd.Args[2]) + } + hasAP := false + hasPartial := false + for i, a := range cmd.Args { + if a == "-aP" && i > 2 { + hasAP = true + } + if a == "--partial" && i > 2 { + hasPartial = true + } + } + if !hasAP { + t.Errorf("cmd.Args = %v, want -aP flag preserved (not dropped)", cmd.Args) + } + if !hasPartial { + t.Errorf("cmd.Args = %v, want --partial flag present", cmd.Args) + } +}