Compare commits

...

2 Commits

Author SHA1 Message Date
darroyo 2ec031c9dc Bump version to 1.0.48 2026-07-17 18:40:23 -04:00
darroyo b2172145e7 Always copy source directory contents, not the directory itself 2026-07-17 18:40:16 -04:00
5 changed files with 103 additions and 10 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=syncserver
VERSION?=1.0.47
VERSION?=1.0.48
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
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"github.com/syncserver/internal/syncengine"
)
var version = "1.0.47"
var version = "1.0.48"
func main() {
cfgPath := flag.String("config", "", "Path to config.yaml")
+15 -2
View File
@@ -69,15 +69,28 @@ func (r *RsyncRunner) buildArgs(pair *SyncPairConfig) []string {
args = append(args, "--delete")
}
src := ensureDirSlash(pair.Source)
if pair.Direction == "pull" {
args = append(args, pair.Dest, pair.Source)
args = append(args, pair.Dest, src)
} else {
args = append(args, pair.Source, pair.Dest)
args = append(args, src, pair.Dest)
}
return args
}
// ensureDirSlash guarantees the source path is treated by rsync as a
// directory whose contents are copied, regardless of whether the user
// supplied a trailing slash. This avoids the common foot-gun where
// "rsync host:/path/series /dest/" creates /dest/series/<contents> nested
// inside an extra "series" subdirectory.
func ensureDirSlash(p string) string {
if strings.HasSuffix(p, "/") {
return p
}
return p + "/"
}
type MachineKeys struct {
Host string
Port int
+80 -6
View File
@@ -40,8 +40,8 @@ func TestBuildArgs_Push(t *testing.T) {
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)-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")
@@ -61,8 +61,8 @@ func TestBuildArgs_Pull(t *testing.T) {
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")
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/")
}
}
@@ -162,8 +162,8 @@ func TestRunRemote_PushSrcAndDestStayAsIs(t *testing.T) {
}
srcArg, dstArg := tc.build()
if srcArg != "/share/homes/admin/media" {
t.Errorf("push src = %q, want raw path '/share/homes/admin/media' (no user@host: prefix added)", srcArg)
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)
@@ -193,6 +193,80 @@ func TestRunRemote_PullSrcAndDestStayAsIs(t *testing.T) {
}
}
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)
}
+6
View File
@@ -375,6 +375,9 @@ export default function SyncPairs() {
setForm({ ...form, source_path: e.target.value })
}
/>
<p className="text-xs text-fg-subtle">
Directory on the source machine. Its <span className="font-medium">contents</span> will be copied into the destination. Trailing <code className="font-mono">/</code> is optional.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-dest-path" required>
@@ -388,6 +391,9 @@ export default function SyncPairs() {
setForm({ ...form, dest_path: e.target.value })
}
/>
<p className="text-xs text-fg-subtle">
Directory on the destination machine where the source contents will land.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">