diff --git a/cmd/archive/extract/extract.go b/cmd/archive/extract/extract.go index 852a2fca6..3ca1f64e2 100644 --- a/cmd/archive/extract/extract.go +++ b/cmd/archive/extract/extract.go @@ -147,6 +147,16 @@ func ArchiveExtract(ctx context.Context, dst fs.Fs, dstDir string, src fs.Fs, sr // extract files err = ex.Extract(ctx, in, func(ctx context.Context, f archives.FileInfo) error { remote := f.NameInArchive + // Strip leading "./" from archive paths. Tar files created with + // relative paths (e.g. "tar -czf archive.tar.gz .") use "./" prefixed + // entries. Without stripping, rclone encodes the "." as a full-width + // dot character creating a spurious directory. We only strip "./" + // specifically to avoid enabling path traversal attacks via "../". + remote = strings.TrimPrefix(remote, "./") + // If the entry was exactly "./" (the root dir), skip it + if remote == "" && f.IsDir() { + return nil + } if dstDir != "" { remote = path.Join(dstDir, remote) } diff --git a/cmd/archive/extract/extract_test.go b/cmd/archive/extract/extract_test.go new file mode 100644 index 000000000..867e3c358 --- /dev/null +++ b/cmd/archive/extract/extract_test.go @@ -0,0 +1,62 @@ +//go:build !plan9 + +package extract + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStripDotSlashPrefix(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "strip leading dot-slash from file", + input: "./file.txt", + expected: "file.txt", + }, + { + name: "strip leading dot-slash from nested path", + input: "./subdir/file.txt", + expected: "subdir/file.txt", + }, + { + name: "no prefix unchanged", + input: "file.txt", + expected: "file.txt", + }, + { + name: "nested path unchanged", + input: "dir/file.txt", + expected: "dir/file.txt", + }, + { + name: "dot-dot-slash NOT stripped (path traversal safety)", + input: "../etc/passwd", + expected: "../etc/passwd", + }, + { + name: "dot-slash directory entry becomes empty", + input: "./", + expected: "", + }, + { + name: "only single leading dot-slash stripped", + input: "././file.txt", + expected: "./file.txt", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // This mirrors the stripping logic in ArchiveExtract + got := strings.TrimPrefix(tc.input, "./") + assert.Equal(t, tc.expected, got) + }) + } +}