package syncengine import ( "context" "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) { runner := NewRsyncRunner("/tmp/ssh", "") args := runner.buildArgs(&SyncPairConfig{ Source: tc.srcPath, Dest: tc.dstPath, Direction: tc.direction, }) sourcePath := args[len(args)-2] destPath := args[len(args)-1] return sourcePath, destPath } 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 (auto-appended trailing slash)", 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 (auto-appended trailing slash)", 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_PushSrcAndDestStayAsIs(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 srcArg != "/share/homes/admin/media/" { t.Errorf("push src = %q, want '/share/homes/admin/media/' (auto-appended trailing slash, no user@host: prefix added)", srcArg) } if dstArg != "/share/media/peliculas" { t.Errorf("push dst = %q, want raw path '/share/media/peliculas'", dstArg) } } func TestRunRemote_PullSrcAndDestStayAsIs(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, dstArg := tc.build() if countOccurrences(srcArg, "@") > 1 { t.Errorf("pull src %q has double SSH spec", srcArg) } if srcArg != "/share/data" { t.Errorf("pull src (rsync dest) = %q, want raw '/share/data'", srcArg) } if !strings.HasPrefix(dstArg, "admin@10.5.0.144:/") { t.Errorf("pull dst (rsync src) = %q, want 'admin@10.5.0.144:/...' prefix", dstArg) } } func TestBuildArgs_AutoAppendsTrailingSlashToSource(t *testing.T) { cases := []struct { name string source string dest string direction string wantSrc string }{ { name: "push, source without trailing slash", source: "/mnt/storage/multimedia/series", dest: "/share/media/series", direction: "push", wantSrc: "/mnt/storage/multimedia/series/", }, { name: "push, source already has trailing slash (idempotent)", source: "/mnt/storage/multimedia/series/", dest: "/share/media/series", direction: "push", wantSrc: "/mnt/storage/multimedia/series/", }, { name: "push, remote source without trailing slash", source: "admin@baby-nas:/mnt/storage/multimedia/series", dest: "/share/media/series", direction: "push", wantSrc: "admin@baby-nas:/mnt/storage/multimedia/series/", }, { name: "pull, source without trailing slash", source: "admin@baby-nas:/mnt/storage/multimedia/series", dest: "/share/media/series", direction: "pull", wantSrc: "admin@baby-nas:/mnt/storage/multimedia/series/", }, { name: "mirror, source without trailing slash", source: "/mnt/storage/multimedia/series", dest: "admin@10.5.0.144:/share/media/series", direction: "mirror", wantSrc: "/mnt/storage/multimedia/series/", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := NewRsyncRunner("/tmp/ssh", "") pair := &SyncPairConfig{ Source: tc.source, Dest: tc.dest, Direction: tc.direction, } args := runner.buildArgs(pair) var gotSrc, gotDest string if tc.direction == "pull" { gotDest = args[len(args)-2] gotSrc = args[len(args)-1] } else { gotSrc = args[len(args)-2] gotDest = args[len(args)-1] } if gotSrc != tc.wantSrc { t.Errorf("source = %q, want %q (dest must never be touched)", gotSrc, tc.wantSrc) } if gotDest != tc.dest { t.Errorf("dest = %q, want %q (dest must never be normalized)", gotDest, tc.dest) } }) } } 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(context.Background(), 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) } }