Repository: peak/s5cmd (go)

Issue:
s3 virtual host access resolution inconsistent
Hi,

I've read through the release notes and can see that in v1 it states: Virtual host style bucket name resolving is enabled by default for S3 and GCSS. However this doesn't seem to be the case.

I've created a new bucket miles-bucket-us-east-1

s3://miles-bucket-us-east-1/ would translate to https://miles-test-us-east-1.s3.amazonaws.com however from my network traffic logs I can see that the traffic defaults to https://s3.amazonaws.com/miles-bucket-us-east-1

Command to replicate:
``` 
s5cmd ls s3://miles-bucket-us-east-1/
```

I can't see any options that I need to specify to force the s5cmd to use virtual host. Am I doing something wrong?

Pull request description:
storage: use session path style in HeadBucket call
Resolves #314

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbcd4fe..b8f8b7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@
 - Fixed a bug about precedence of region detection, which auto region detection would always override region defined in environment or profile. ([#325](https://github.com/peak/s5cmd/issues/325))
 - Fixed a bug where errors did not result a non-zero exit code. ([#304](https://github.com/peak/s5cmd/issues/304))
 - Print error if the commands file of `run` command is not accessible. ([#410](https://github.com/peak/s5cmd/pull/410))
+- Updated region detection call to use current session's address resolving method ([#314](https://github.com/peak/s5cmd/issues/314))
 
 ## v1.4.0 - 21 Sep 2021
 
diff --git a/command/app.go b/command/app.go
index 5a28165..fa9e983 100644
--- a/command/app.go
+++ b/command/app.go
@@ -82,7 +82,7 @@ var app = &cli.App{
 
 		if retryCount < 0 {
 			err := fmt.Errorf("retry count cannot be a negative value")
-			printError(givenCommand(c), c.Command.Name, err)
+			printError(commandFromContext(c), c.Command.Name, err)
 			return err
 		}
 
@@ -154,8 +154,8 @@ func Commands() []*cli.Command {
 		NewSizeCommand(),
 		NewCatCommand(),
 		NewRunCommand(),
-		NewVersionCommand(),
 		NewSyncCommand(),
+		NewVersionCommand(),
 	}
 }
 
diff --git a/command/cat.go b/command/cat.go
index dcdb2d1..0e66afd 100644
--- a/command/cat.go
+++ b/command/cat.go
@@ -36,7 +36,7 @@ func NewCatCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateCatCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -45,7 +45,7 @@ func NewCatCommand() *cli.Command {
 
 			src, err := url.New(c.Args().Get(0))
 			op := c.Command.Name
-			fullCommand := givenCommand(c)
+			fullCommand := commandFromContext(c)
 			if err != nil {
 				printError(fullCommand, op, err)
 				return err
diff --git a/command/context.go b/command/context.go
new file mode 100644
index 0000000..b6d9190
--- /dev/null
+++ b/command/context.go
@@ -0,0 +1,106 @@
+package command
+
+import (
+	"flag"
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/peak/s5cmd/storage/url"
+	"github.com/urfave/cli/v2"
+)
+
+func commandFromContext(c *cli.Context) string {
+	cmd := c.Command.FullName()
+
+	for _, f := range c.Command.Flags {
+		flagname := f.Names()[0]
+		for _, flagvalue := range contextValue(c, flagname) {
+			cmd = fmt.Sprintf("%s --%s=%v", cmd, flagname, flagvalue)
+		}
+	}
+
+	if c.Args().Len() > 0 {
+		cmd = fmt.Sprintf("%v %v", cmd, strings.Join(c.Args().Slice(), " "))
+	}
+
+	return cmd
+}
+
+// contextValue traverses context and its ancestor contexts to find
+// the flag value and returns string slice.
+func contextValue(c *cli.Context, flagname string) []string {
+	for _, c := range c.Lineage() {
+		if !c.IsSet(flagname) {
+			continue
+		}
+
+		val := c.Value(flagname)
+		switch val.(type) {
+		case cli.StringSlice:
+			return c.StringSlice(flagname)
+		case cli.Int64Slice, cli.IntSlice:
+			values := c.Int64Slice(flagname)
+			var result []string
+			for _, v := range values {
+				result = append(result, strconv.FormatInt(v, 10))
+			}
+			return result
+		case string:
+			return []string{c.String(flagname)}
+		case bool:
+			return []string{strconv.FormatBool(c.Bool(flagname))}
+		case int, int64:
+			return []string{strconv.FormatInt(c.Int64(flagname), 10)}
+		default:
+			return []string{fmt.Sprintf("%v", val)}
+		}
+	}
+
+	return nil
+}
+
+// generateCommand generates command string from given context, app command, default flags and urls.
+func generateCommand(c *cli.Context, cmd string, defaultFlags map[string]interface{}, urls ...*url.URL) (string, error) {
+	command := AppCommand(cmd)
+	flagset := flag.NewFlagSet(command.Name, flag.ContinueOnError)
+
+	var args []string
+	for _, url := range urls {
+		args = append(args, fmt.Sprintf("%q", url.String()))
+	}
+
+	flags := []string{}
+	for flagname, flagvalue := range defaultFlags {
+		flags = append(flags, fmt.Sprintf("--%s=%v", flagname, flagvalue))
+	}
+
+	isDefaultFlag := func(flagname string) bool {
+		_, ok := defaultFlags[flagname]
+		return ok
+	}
+
+	for _, f := range command.Flags {
+		flagname := f.Names()[0]
+		if isDefaultFlag(flagname) || !c.IsSet(flagname) {
+			continue
+		}
+
+		for _, flagvalue := range contextValue(c, flagname) {
+			flags = append(flags, fmt.Sprintf("--%s=%s", flagname, flagvalue))
+		}
+	}
+
+	sort.Strings(flags)
+	flags = append(flags, args...)
+	flags = append([]string{command.Name}, flags...)
+
+	err := flagset.Parse(flags)
+	if err != nil {
+		return "", err
+	}
+
+	cmdCtx := cli.NewContext(c.App, flagset, c)
+	return strings.TrimSpace(commandFromContext(cmdCtx)), nil
+}
diff --git a/command/cp.go b/command/cp.go
index 0003bfc..5c7d427 100644
--- a/command/cp.go
+++ b/command/cp.go
@@ -96,28 +96,8 @@ Examples:
 		 > s5cmd {{.HelpName}} --exclude "log*" s3://bucket/* s3://destbucket
 `
 
-func NewCopyCommandFlags() []cli.Flag {
+func NewSharedFlags() []cli.Flag {
 	return []cli.Flag{
-		&cli.BoolFlag{
-			Name:    "no-clobber",
-			Aliases: []string{"n"},
-			Usage:   "do not overwrite destination if already exists",
-		},
-		&cli.BoolFlag{
-			Name:    "if-size-differ",
-			Aliases: []string{"s"},
-			Usage:   "only overwrite destination if size differs",
-		},
-		&cli.BoolFlag{
-			Name:    "if-source-newer",
-			Aliases: []string{"u"},
-			Usage:   "only overwrite destination if source modtime is newer",
-		},
-		&cli.BoolFlag{
-			Name:    "flatten",
-			Aliases: []string{"f"},
-			Usage:   "flatten directory structure of source, starting from the first wildcard",
-		},
 		&cli.BoolFlag{
 			Name:  "no-follow-symlinks",
 			Usage: "do not follow symbolic links",
@@ -185,6 +165,33 @@ func NewCopyCommandFlags() []cli.Flag {
 	}
 }
 
+func NewCopyCommandFlags() []cli.Flag {
+	copyFlags := []cli.Flag{
+		&cli.BoolFlag{
+			Name:    "flatten",
+			Aliases: []string{"f"},
+			Usage:   "flatten directory structure of source, starting from the first wildcard",
+		},
+		&cli.BoolFlag{
+			Name:    "no-clobber",
+			Aliases: []string{"n"},
+			Usage:   "do not overwrite destination if already exists",
+		},
+		&cli.BoolFlag{
+			Name:    "if-size-differ",
+			Aliases: []string{"s"},
+			Usage:   "only overwrite destination if size differs",
+		},
+		&cli.BoolFlag{
+			Name:    "if-source-newer",
+			Aliases: []string{"u"},
+			Usage:   "only overwrite destination if source modtime is newer",
+		},
+	}
+	sharedFlags := NewSharedFlags()
+	return append(copyFlags, sharedFlags...)
+}
+
 func NewCopyCommand() *cli.Command {
 	return &cli.Command{
 		Name:               "cp",
@@ -195,7 +202,7 @@ func NewCopyCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateCopyCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -250,7 +257,7 @@ func NewCopy(c *cli.Context, deleteSource bool) Copy {
 		src:          c.Args().Get(0),
 		dst:          c.Args().Get(1),
 		op:           c.Command.Name,
-		fullCommand:  givenCommand(c),
+		fullCommand:  commandFromContext(c),
 		deleteSource: deleteSource,
 		// flags
 		noClobber:             c.Bool("no-clobber"),
@@ -476,7 +483,7 @@ func (c Copy) doDownload(ctx context.Context, srcurl *url.URL, dsturl *url.URL)
 	if err != nil {
 		// FIXME(ig): rename
 		if errorpkg.IsWarning(err) {
-			printDebug(c.op, srcurl, dsturl, err)
+			printDebug(c.op, err, srcurl, dsturl)
 			return nil
 		}
 		return err
@@ -523,7 +530,7 @@ func (c Copy) doUpload(ctx context.Context, srcurl *url.URL, dsturl *url.URL) er
 	err = c.shouldOverride(ctx, srcurl, dsturl)
 	if err != nil {
 		if errorpkg.IsWarning(err) {
-			printDebug(c.op, srcurl, dsturl, err)
+			printDebug(c.op, err, srcurl, dsturl)
 			return nil
 		}
 		return err
@@ -598,7 +605,7 @@ func (c Copy) doCopy(ctx context.Context, srcurl, dsturl *url.URL) error {
 	err = c.shouldOverride(ctx, srcurl, dsturl)
 	if err != nil {
 		if errorpkg.IsWarning(err) {
-			printDebug(c.op, srcurl, dsturl, err)
+			printDebug(c.op, err, srcurl, dsturl)
 			return nil
 		}
 		return err
@@ -870,12 +877,3 @@ func guessContentType(file *os.File) string {
 	}
 	return contentType
 }
-
-func givenCommand(c *cli.Context) string {
-	cmd := c.Command.FullName()
-	if c.Args().Len() > 0 {
-		cmd = fmt.Sprintf("%v %v", cmd, strings.Join(c.Args().Slice(), " "))
-	}
-
-	return cmd
-}
diff --git a/command/du.go b/command/du.go
index 4b7676c..8719101 100644
--- a/command/du.go
+++ b/command/du.go
@@ -60,7 +60,7 @@ func NewSizeCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateDUCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -70,7 +70,7 @@ func NewSizeCommand() *cli.Command {
 			return Size{
 				src:         c.Args().First(),
 				op:          c.Command.Name,
-				fullCommand: givenCommand(c),
+				fullCommand: commandFromContext(c),
 				// flags
 				groupByClass: c.Bool("group"),
 				humanize:     c.Bool("humanize"),
diff --git a/command/error.go b/command/error.go
index 5964053..659246a 100644
--- a/command/error.go
+++ b/command/error.go
@@ -11,9 +11,14 @@ import (
 	"github.com/peak/s5cmd/storage/url"
 )
 
-func printDebug(op string, src, dst *url.URL, err error) {
+func printDebug(op string, err error, urls ...*url.URL) {
+	command := op
+	for _, url := range urls {
+		command += fmt.Sprintf(" %s", url)
+	}
+
 	msg := log.DebugMessage{
-		Command:   fmt.Sprintf("%v %v %v", op, src, dst),
+		Command:   command,
 		Operation: op,
 		Err:       cleanupError(err),
 	}
diff --git a/command/ls.go b/command/ls.go
index 2720f7e..e9ffc88 100644
--- a/command/ls.go
+++ b/command/ls.go
@@ -74,7 +74,7 @@ func NewListCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateLSCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -83,7 +83,7 @@ func NewListCommand() *cli.Command {
 			if !c.Args().Present() {
 				err := ListBuckets(c.Context, NewStorageOpts(c))
 				if err != nil {
-					printError(givenCommand(c), c.Command.Name, err)
+					printError(commandFromContext(c), c.Command.Name, err)
 				}
 				return err
 			}
@@ -91,7 +91,7 @@ func NewListCommand() *cli.Command {
 			return List{
 				src:         c.Args().First(),
 				op:          c.Command.Name,
-				fullCommand: givenCommand(c),
+				fullCommand: commandFromContext(c),
 				// flags
 				showEtag:         c.Bool("etag"),
 				humanize:         c.Bool("humanize"),
diff --git a/command/mb.go b/command/mb.go
index 7d2b811..9a70f99 100644
--- a/command/mb.go
+++ b/command/mb.go
@@ -35,7 +35,7 @@ func NewMakeBucketCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateMBCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -45,7 +45,7 @@ func NewMakeBucketCommand() *cli.Command {
 			return MakeBucket{
 				src:         c.Args().First(),
 				op:          c.Command.Name,
-				fullCommand: givenCommand(c),
+				fullCommand: commandFromContext(c),
 
 				storageOpts: NewStorageOpts(c),
 			}.Run(c.Context)
diff --git a/command/rb.go b/command/rb.go
index d1ef4f4..df8eac0 100644
--- a/command/rb.go
+++ b/command/rb.go
@@ -34,7 +34,7 @@ func NewRemoveBucketCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateMBCommand(c) // uses same validation function with make bucket command.
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -44,7 +44,7 @@ func NewRemoveBucketCommand() *cli.Command {
 			return RemoveBucket{
 				src:         c.Args().First(),
 				op:          c.Command.Name,
-				fullCommand: givenCommand(c),
+				fullCommand: commandFromContext(c),
 
 				storageOpts: NewStorageOpts(c),
 			}.Run(c.Context)
diff --git a/command/rm.go b/command/rm.go
index 08e53af..85d9194 100644
--- a/command/rm.go
+++ b/command/rm.go
@@ -59,7 +59,7 @@ func NewDeleteCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateRMCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -68,7 +68,7 @@ func NewDeleteCommand() *cli.Command {
 			return Delete{
 				src:         c.Args().Slice(),
 				op:          c.Command.Name,
-				fullCommand: givenCommand(c),
+				fullCommand: commandFromContext(c),
 
 				// flags
 				raw:     c.Bool("raw"),
diff --git a/command/run.go b/command/run.go
index a54f6c6..d7596a9 100644
--- a/command/run.go
+++ b/command/run.go
@@ -42,7 +42,7 @@ func NewRunCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateRunCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -51,7 +51,7 @@ func NewRunCommand() *cli.Command {
 			if c.Args().Len() == 1 {
 				f, err := os.Open(c.Args().First())
 				if err != nil {
-					printError(givenCommand(c), c.Command.Name, err)
+					printError(commandFromContext(c), c.Command.Name, err)
 					return err
 				}
 				defer f.Close()
@@ -125,7 +125,7 @@ func (r Run) Run(ctx context.Context) error {
 
 		if fields[0] == "run" {
 			err := fmt.Errorf("%q command (line: %v) is not permitted in run-mode", "run", lineno)
-			printError(givenCommand(r.c), r.c.Command.Name, err)
+			printError(commandFromContext(r.c), r.c.Command.Name, err)
 			continue
 		}
 
@@ -135,13 +135,13 @@ func (r Run) Run(ctx context.Context) error {
 			cmd := AppCommand(subcmd)
 			if cmd == nil {
 				err := fmt.Errorf("%q command (line: %v) not found", subcmd, lineno)
-				printError(givenCommand(r.c), r.c.Command.Name, err)
+				printError(commandFromContext(r.c), r.c.Command.Name, err)
 				return nil
 			}
 
 			flagset := flag.NewFlagSet(subcmd, flag.ExitOnError)
 			if err := flagset.Parse(fields); err != nil {
-				printError(givenCommand(r.c), r.c.Command.Name, err)
+				printError(commandFromContext(r.c), r.c.Command.Name, err)
 				return nil
 			}
 
diff --git a/command/select.go b/command/select.go
index 97a05c3..83ed871 100644
--- a/command/select.go
+++ b/command/select.go
@@ -69,7 +69,7 @@ func NewSelectCommand() *cli.Command {
 		Before: func(c *cli.Context) error {
 			err := validateSelectCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -79,7 +79,7 @@ func NewSelectCommand() *cli.Command {
 			return Select{
 				src:         c.Args().Get(0),
 				op:          c.Command.Name,
-				fullCommand: givenCommand(c),
+				fullCommand: commandFromContext(c),
 				// flags
 				query:                 c.String("query"),
 				compressionType:       c.String("compression"),
diff --git a/command/sync.go b/command/sync.go
index f336573..09d862d 100644
--- a/command/sync.go
+++ b/command/sync.go
@@ -31,36 +31,38 @@ Options:
 	{{end}}
 Examples:
 	01. Sync local folder to s3 bucket
-		> s5cmd {{.HelpName}} folder/ s3://bucket/
+		 > s5cmd {{.HelpName}} folder/ s3://bucket/
 
 	02. Sync S3 bucket to local folder
-		> s5cmd {{.HelpName}} s3://bucket/* folder/
+		 > s5cmd {{.HelpName}} s3://bucket/* folder/
 
 	03. Sync S3 bucket objects under prefix to S3 bucket.
-		> s5cmd {{.HelpName}} s3://sourcebucket/prefix/* s3://destbucket/
+		 > s5cmd {{.HelpName}} s3://sourcebucket/prefix/* s3://destbucket/
 
 	04. Sync local folder to S3 but delete the files that S3 bucket has but local does not have.
-		> s5cmd {{.HelpName}} --delete folder/ s3://bucket/
+		 > s5cmd {{.HelpName}} --delete folder/ s3://bucket/
 
 	05. Sync S3 bucket to local folder but use size as only comparison criteria.
-		> s5cmd {{.HelpName}} --size-only s3://bucket/* folder/
-	
+		 > s5cmd {{.HelpName}} --size-only s3://bucket/* folder/
+
+	06. Sync a file to S3 bucket
+		 > s5cmd {{.HelpName}} myfile.gz s3://bucket/
+
+	07. Sync matching S3 objects to another bucket
+		 > s5cmd {{.HelpName}} s3://bucket/*.gz s3://target-bucket/prefix/
+
+	08. Perform KMS Server Side Encryption of the object(s) at the destination
+		 > s5cmd {{.HelpName}} --sse aws:kms s3://bucket/object s3://target-bucket/prefix/object
+
+	09. Perform KMS-SSE of the object(s) at the destination using customer managed Customer Master Key (CMK) key id
+		 > s5cmd {{.HelpName}} --sse aws:kms --sse-kms-key-id <your-kms-key-id> s3://bucket/object s3://target-bucket/prefix/object
+
+	10. Sync all files to S3 bucket but exclude the ones with txt and gz extension
+		 > s5cmd {{.HelpName}} --exclude "*.txt" --exclude "*.gz" dir/ s3://bucket
 `
 
 func NewSyncCommandFlags() []cli.Flag {
-	return []cli.Flag{
-		&cli.IntFlag{
-			Name:    "concurrency",
-			Aliases: []string{"c"},
-			Value:   defaultCopyConcurrency,
-			Usage:   "number of concurrent parts transferred between host and remote server",
-		},
-		&cli.IntFlag{
-			Name:    "part-size",
-			Aliases: []string{"p"},
-			Value:   defaultPartSize,
-			Usage:   "size of each part transferred between host and remote server, in MiB",
-		},
+	syncFlags := []cli.Flag{
 		&cli.BoolFlag{
 			Name:  "delete",
 			Usage: "delete objects in destination but not in source",
@@ -70,6 +72,8 @@ func NewSyncCommandFlags() []cli.Flag {
 			Usage: "make size of object only criteria to decide whether an object should be synced",
 		},
 	}
+	sharedFlags := NewSharedFlags()
+	return append(syncFlags, sharedFlags...)
 }
 
 func NewSyncCommand() *cli.Command {
@@ -80,9 +84,10 @@ func NewSyncCommand() *cli.Command {
 		Flags:              NewSyncCommandFlags(),
 		CustomHelpTemplate: syncHelpTemplate,
 		Before: func(c *cli.Context) error {
-			err := validateSyncCommand(c)
+			// sync command share same validation method as copy command
+			err := validateCopyCommand(c)
 			if err != nil {
-				printError(givenCommand(c), c.Command.Name, err)
+				printError(commandFromContext(c), c.Command.Name, err)
 			}
 			return err
 		},
@@ -110,9 +115,14 @@ type Sync struct {
 	sizeOnly bool
 
 	// s3 options
-	concurrency int
-	partSize    int64
 	storageOpts storage.Options
+
+	followSymlinks bool
+	storageClass   storage.StorageClass
+	raw            bool
+
+	srcRegion string
+	dstRegion string
 }
 
 // NewSync creates Sync from cli.Context
@@ -121,15 +131,19 @@ func NewSync(c *cli.Context) Sync {
 		src:         c.Args().Get(0),
 		dst:         c.Args().Get(1),
 		op:          c.Command.Name,
-		fullCommand: givenCommand(c),
+		fullCommand: commandFromContext(c),
 
 		// flags
 		delete:   c.Bool("delete"),
 		sizeOnly: c.Bool("size-only"),
 
-		// s3 options
-		partSize:    c.Int64("part-size") * megabytes,
-		concurrency: c.Int("concurrency"),
+		// flags
+		followSymlinks: !c.Bool("no-follow-symlinks"),
+		storageClass:   storage.StorageClass(c.String("storage-class")),
+		raw:            c.Bool("raw"),
+		// region settings
+		srcRegion:   c.String("source-region"),
+		dstRegion:   c.String("destination-region"),
 		storageOpts: NewStorageOpts(c),
 	}
 }
@@ -137,12 +151,12 @@ func NewSync(c *cli.Context) Sync {
 // Run compares files, plans necessary s5cmd commands to execute
 // and executes them in order to sync source to destination.
 func (s Sync) Run(c *cli.Context) error {
-	srcurl, err := url.New(s.src)
+	srcurl, err := url.New(s.src, url.WithRaw(s.raw))
 	if err != nil {
 		return err
 	}
 
-	dsturl, err := url.New(s.dst)
+	dsturl, err := url.New(s.dst, url.WithRaw(s.raw))
 	if err != nil {
 		return err
 	}
@@ -192,7 +206,7 @@ func (s Sync) Run(c *cli.Context) error {
 	pipeReader, pipeWriter := io.Pipe() // create a reader, writer pipe to pass commands to run
 
 	// Create commands in background.
-	go s.planRun(onlySource, onlyDest, commonObjects, dsturl, strategy, pipeWriter, isBatch)
+	go s.planRun(c, onlySource, onlyDest, commonObjects, dsturl, strategy, pipeWriter, isBatch)
 
 	err = NewRun(c, pipeReader).Run(c.Context)
 	return multierror.Append(err, merrorWaiter).ErrorOrNil()
@@ -275,7 +289,7 @@ func (s Sync) getSourceAndDestinationObjects(ctx context.Context, srcurl, dsturl
 	wg.Add(1)
 	go func() {
 		defer wg.Done()
-		srcObjectChannel := sourceClient.List(ctx, srcurl, false)
+		srcObjectChannel := sourceClient.List(ctx, srcurl, s.followSymlinks)
 		for srcObject := range srcObjectChannel {
 			if s.shouldSkipObject(srcObject, true) {
 				continue
@@ -316,6 +330,7 @@ func (s Sync) getSourceAndDestinationObjects(ctx context.Context, srcurl, dsturl
 
 // planRun prepares the commands and writes them to writer 'w'.
 func (s Sync) planRun(
+	c *cli.Context,
 	onlySource, onlyDest []*url.URL,
 	common []*ObjectPair,
 	dsturl *url.URL,
@@ -323,13 +338,24 @@ func (s Sync) planRun(
 	w io.WriteCloser,
 	isBatch bool,
 ) {
+	defer w.Close()
+
+	// Always use raw mode since sync command generates commands
+	// from raw S3 objects. Otherwise, generated copy command will
+	// try to expand given source.
+	defaultFlags := map[string]interface{}{
+		"raw": true,
+	}
 
 	// only in source
 	for _, srcurl := range onlySource {
 		curDestURL := generateDestinationURL(srcurl, dsturl, isBatch)
-
-		command := fmt.Sprintf("cp %v %v\n", srcurl, curDestURL)
-		fmt.Fprint(w, command)
+		command, err := generateCommand(c, "cp", defaultFlags, srcurl, curDestURL)
+		if err != nil {
+			printDebug(s.op, err, srcurl, curDestURL)
+			continue
+		}
+		fmt.Fprintln(w, command)
 	}
 
 	// both in source and destination
@@ -338,26 +364,27 @@ func (s Sync) planRun(
 		curSourceURL, curDestURL := sourceObject.URL, destObject.URL
 		err := strategy.ShouldSync(sourceObject, destObject) // check if object should be copied.
 		if err != nil {
-			printDebug(s.op, curSourceURL, curDestURL, err)
+			printDebug(s.op, err, curSourceURL, curDestURL)
 			continue
 		}
 
-		command := fmt.Sprintf("cp %v %v\n", curSourceURL, curDestURL)
-		fmt.Fprint(w, command)
+		command, err := generateCommand(c, "cp", defaultFlags, curSourceURL, curDestURL)
+		if err != nil {
+			printDebug(s.op, err, curSourceURL, curDestURL)
+			continue
+		}
+		fmt.Fprintln(w, command)
 	}
 
 	// only in destination
 	if s.delete && len(onlyDest) > 0 {
-		var objectsToDelete []string
-		for _, obj := range onlyDest {
-			objectsToDelete = append(objectsToDelete, obj.String())
+		command, err := generateCommand(c, "rm", defaultFlags, onlyDest...)
+		if err != nil {
+			printDebug(s.op, err, onlyDest...)
+			return
 		}
-
-		command := fmt.Sprintf("rm %v\n", strings.Join(objectsToDelete, " "))
-		fmt.Fprint(w, command)
+		fmt.Fprintln(w, command)
 	}
-
-	w.Close()
 }
 
 // generateDestinationURL generates destination url for given
@@ -401,100 +428,3 @@ func (s Sync) shouldSkipObject(object *storage.Object, verbose bool) bool {
 	}
 	return false
 }
-
-func validateSyncCommand(c *cli.Context) error {
-	if c.Args().Len() != 2 {
-		return fmt.Errorf("expected source and destination arguments")
-	}
-
-	ctx := c.Context
-	src := c.Args().Get(0)
-	dst := c.Args().Get(1)
-
-	srcurl, err := url.New(src)
-	if err != nil {
-		return err
-	}
-
-	dsturl, err := url.New(dst)
-	if err != nil {
-		return err
-	}
-
-	// wildcard destination doesn't mean anything
-	if dsturl.IsWildcard() {
-		return fmt.Errorf("target %q can not contain glob characters", dst)
-	}
-
-	// we don't operate on S3 prefixes for copy and delete operations.
-	if srcurl.IsBucket() || srcurl.IsPrefix() {
-		return fmt.Errorf("source argument must contain wildcard character")
-	}
-
-	// 'cp dir/* s3://bucket/prefix': expect a trailing slash to avoid any
-	// surprises.
-	if srcurl.IsWildcard() && dsturl.IsRemote() && !dsturl.IsPrefix() && !dsturl.IsBucket() {
-		return fmt.Errorf("target %q must be a bucket or a prefix", dsturl)
-	}
-
-	switch {
-	case srcurl.Type == dsturl.Type:
-		return validateSyncCopy(srcurl, dsturl)
-	case dsturl.IsRemote():
-		return validateSyncUpload(ctx, srcurl, dsturl, NewStorageOpts(c))
-	case srcurl.IsRemote():
-		return validateSyncDownload(srcurl)
-	default:
-		return nil
-	}
-}
-
-func validateSyncCopy(srcurl, dsturl *url.URL) error {
-	if srcurl.IsRemote() || dsturl.IsRemote() {
-		return nil
-	}
-
-	// we don't support local->local copies
-	return fmt.Errorf("local->local sync operations are not permitted")
-}
-
-func validateSyncUpload(ctx context.Context, srcurl, dsturl *url.URL, storageOpts storage.Options) error {
-	srcclient := storage.NewLocalClient(storageOpts)
-
-	if srcurl.IsWildcard() {
-		return nil
-	}
-
-	obj, err := srcclient.Stat(ctx, srcurl)
-	if err != nil {
-		return err
-	}
-
-	// do not support single file. use 'cp' instead.
-	if !obj.Type.IsDir() {
-		return fmt.Errorf("local source must be a directory")
-	}
-
-	// 'sync dir/ s3://bucket/prefix-without-slash': expect a trailing slash to
-	// avoid any surprises.
-	if obj.Type.IsDir() && !dsturl.IsBucket() && !dsturl.IsPrefix() {
-		return fmt.Errorf("target %q must be a bucket or a prefix", dsturl)
-	}
-
-	return nil
-}
-
-func validateSyncDownload(srcurl *url.URL) error {
-	if srcurl.IsWildcard() {
-		return nil
-	}
-
-	// 'sync s3://bucket/prefix-without-slash dir/': should not work
-	// 'sync s3://bucket/object.go dir/' should not work.
-	// do not support single object.
-	if !srcurl.IsBucket() && !srcurl.IsPrefix() {
-		return fmt.Errorf("remote source %q must be a bucket or a prefix", srcurl)
-	}
-
-	return nil
-}
diff --git a/storage/s3.go b/storage/s3.go
index 7ca292d..ef7844a 100644
--- a/storage/s3.go
+++ b/storage/s3.go
@@ -835,6 +835,10 @@ func setSessionRegion(ctx context.Context, sess *session.Session, bucket string)
 
 	// auto-detection
 	region, err := s3manager.GetBucketRegion(ctx, sess, bucket, "", func(r *request.Request) {
+		// s3manager.GetBucketRegion uses Path style addressing and
+		// AnonymousCredentials by default, updating Request's Config to match
+		// the session config.
+		r.Config.S3ForcePathStyle = sess.Config.S3ForcePathStyle
 		r.Config.Credentials = sess.Config.Credentials
 	})
 	if err != nil {


Test patch:
diff --git a/command/context_test.go b/command/context_test.go
new file mode 100644
index 0000000..cad4b66
--- /dev/null
+++ b/command/context_test.go
@@ -0,0 +1,197 @@
+package command
+
+import (
+	"flag"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/peak/s5cmd/storage/url"
+	"github.com/urfave/cli/v2"
+)
+
+func TestGenerateCommand(t *testing.T) {
+	t.Parallel()
+
+	app := cli.NewApp()
+
+	testcases := []struct {
+		name            string
+		cmd             string
+		flags           []cli.Flag
+		defaultFlags    map[string]interface{}
+		ctx             *cli.Context
+		urls            []*url.URL
+		expectedCommand string
+	}{
+		{
+			name:  "empty-cli-flags",
+			cmd:   "cp",
+			flags: []cli.Flag{},
+			urls: []*url.URL{
+				mustNewURL(t, "s3://bucket/key1"),
+				mustNewURL(t, "s3://bucket/key2"),
+			},
+			expectedCommand: `cp "s3://bucket/key1" "s3://bucket/key2"`,
+		},
+		{
+			name:  "empty-cli-flags-with-default-flags",
+			cmd:   "cp",
+			flags: []cli.Flag{},
+			defaultFlags: map[string]interface{}{
+				"raw": true,
+				"acl": "public-read",
+			},
+			urls: []*url.URL{
+				mustNewURL(t, "s3://bucket/key1"),
+				mustNewURL(t, "s3://bucket/key2"),
+			},
+			expectedCommand: `cp --acl=public-read --raw=true "s3://bucket/key1" "s3://bucket/key2"`,
+		},
+		{
+			name: "same-flag-should-be-ignored-if-given-from-both-default-and-cli-flags",
+			cmd:  "cp",
+			flags: []cli.Flag{
+				&cli.BoolFlag{
+					Name:  "raw",
+					Value: true,
+				},
+			},
+			defaultFlags: map[string]interface{}{
+				"raw": true,
+			},
+			urls: []*url.URL{
+				mustNewURL(t, "s3://bucket/key1"),
+				mustNewURL(t, "s3://bucket/key2"),
+			},
+			expectedCommand: `cp --raw=true "s3://bucket/key1" "s3://bucket/key2"`,
+		},
+		{
+			name: "ignore-non-shared-flag",
+			cmd:  "cp",
+			flags: []cli.Flag{
+				&cli.BoolFlag{
+					Name:  "force-glacier-transfer",
+					Value: true,
+				},
+				&cli.BoolFlag{
+					Name:  "raw",
+					Value: true,
+				},
+				&cli.BoolFlag{
+					Name:  "flatten",
+					Value: true,
+				},
+				&cli.IntFlag{
+					Name:  "concurrency",
+					Value: 6,
+				},
+				// delete is not shared flag, will be ignored
+				&cli.BoolFlag{
+					Name:  "delete",
+					Value: true,
+				},
+				// size-only is not shared flag, will be ignored
+				&cli.BoolFlag{
+					Name:  "size-only",
+					Value: true,
+				},
+			},
+			urls: []*url.URL{
+				mustNewURL(t, "s3://bucket/key1"),
+				mustNewURL(t, "s3://bucket/key2"),
+			},
+			expectedCommand: `cp --concurrency=6 --flatten=true --force-glacier-transfer=true --raw=true "s3://bucket/key1" "s3://bucket/key2"`,
+		},
+		{
+			name: "string-slice-flag",
+			cmd:  "cp",
+			flags: []cli.Flag{
+				&cli.StringSliceFlag{
+					Name:  "exclude",
+					Value: cli.NewStringSlice("*.txt", "*.log"),
+				},
+			},
+			urls: []*url.URL{
+				mustNewURL(t, "/source/dir"),
+				mustNewURL(t, "s3://bucket/prefix/"),
+			},
+			expectedCommand: `cp --exclude=*.log --exclude=*.txt "/source/dir" "s3://bucket/prefix/"`,
+		},
+		{
+			name:  "command-with-multiple-args",
+			cmd:   "rm",
+			flags: []cli.Flag{},
+			urls: []*url.URL{
+				mustNewURL(t, "s3://bucket/key1"),
+				mustNewURL(t, "s3://bucket/key2"),
+				mustNewURL(t, "s3://bucket/prefix/key3"),
+				mustNewURL(t, "s3://bucket/prefix/key4"),
+			},
+			expectedCommand: `rm "s3://bucket/key1" "s3://bucket/key2" "s3://bucket/prefix/key3" "s3://bucket/prefix/key4"`,
+		},
+		{
+			name:  "command-args-with-spaces",
+			cmd:   "rm",
+			flags: []cli.Flag{},
+			urls: []*url.URL{
+				mustNewURL(t, "file with space"),
+				mustNewURL(t, "wow wow"),
+			},
+			expectedCommand: `rm "file with space" "wow wow"`,
+		},
+	}
+	for _, tc := range testcases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			command := AppCommand(tc.cmd)
+			set := flagSet(t, command.Name, tc.flags)
+			ctx := cli.NewContext(app, set, nil)
+
+			// urfave.Cli pass flags values to context before calling command.Action()
+			// and methods to update context are package-private, so write simple
+			// flag parser to update context value.
+			set.VisitAll(func(f *flag.Flag) {
+				value := strings.Trim(f.Value.String(), "[")
+				value = strings.Trim(value, "]")
+				for _, v := range strings.Fields(value) {
+					ctx.Set(f.Name, v)
+				}
+			})
+
+			got, err := generateCommand(ctx, command.Name, tc.defaultFlags, tc.urls...)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if diff := cmp.Diff(tc.expectedCommand, got); diff != "" {
+				t.Errorf("(-want +got):\n%v", diff)
+			}
+		})
+	}
+}
+
+func mustNewURL(t *testing.T, path string) *url.URL {
+	t.Helper()
+
+	u, err := url.New(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return u
+}
+
+func flagSet(t *testing.T, name string, flags []cli.Flag) *flag.FlagSet {
+	t.Helper()
+
+	set := flag.NewFlagSet(name, flag.ContinueOnError)
+	for _, f := range flags {
+		if err := f.Apply(set); err != nil {
+			t.Fatal(err)
+		}
+	}
+	return set
+}
diff --git a/e2e/cp_test.go b/e2e/cp_test.go
index 1a88a6b..fe3a60a 100644
--- a/e2e/cp_test.go
+++ b/e2e/cp_test.go
@@ -3605,7 +3605,6 @@ func TestCopyLocalDirectoryToS3WithExcludeFilter(t *testing.T) {
 			}
 		})
 	}
-
 }
 
 // cp --exclude "*.gz" --exclude "*.txt" dir/ s3://bucket/
diff --git a/e2e/sync_test.go b/e2e/sync_test.go
index 3fdd738..ca44891 100644
--- a/e2e/sync_test.go
+++ b/e2e/sync_test.go
@@ -3,6 +3,7 @@ package e2e
 import (
 	"fmt"
 	"path/filepath"
+	"runtime"
 	"testing"
 	"time"
 
@@ -11,8 +12,36 @@ import (
 	"gotest.tools/v3/icmd"
 )
 
+// sync -n s3://bucket/object file
+func TestSyncFailForNonsharedFlagsFromCopyCommand(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	const (
+		filename = "source.go"
+		bucket   = "bucket"
+	)
+
+	createBucket(t, s3client, bucket)
+	putFile(t, s3client, bucket, filename, "content")
+
+	srcpath := fmt.Sprintf("s3://%s/%s", bucket, filename)
+
+	cmd := s5cmd("sync", "-n", srcpath, ".")
+	result := icmd.RunCmd(cmd)
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+
+	// urfave.Cli prints the help text and error message to stdout
+	// if given flags in not present in command options.
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals("Incorrect Usage: flag provided but not defined: -n"),
+	}, strictLineCheck(false))
+}
+
 // sync folder/ folder2/
-func TestSyncLocalToLocalNotPermitted(t *testing.T) {
+func TestSyncLocalToLocal(t *testing.T) {
 	t.Parallel()
 
 	_, s5cmd, cleanup := setup(t)
@@ -30,58 +59,135 @@ func TestSyncLocalToLocalNotPermitted(t *testing.T) {
 	result.Assert(t, icmd.Expected{ExitCode: 1})
 
 	assertLines(t, result.Stderr(), map[int]compareFunc{
-		0: equals(`ERROR "sync %s %s": local->local sync operations are not permitted`, srcpath, destpath),
+		0: equals(`ERROR "sync %s %s": local->local copy operations are not permitted`, srcpath, destpath),
 	})
 }
 
-// sync source.go s3://bucket
-func TestSyncLocalFileToS3NotPermitted(t *testing.T) {
+// sync s3://bucket/source.go .
+func TestSyncSingleS3ObjectToLocalTwice(t *testing.T) {
 	t.Parallel()
 
 	s3client, s5cmd, cleanup := setup(t)
 	defer cleanup()
 
-	const bucket = "bucket"
+	const (
+		filename = "source.go"
+		bucket   = "bucket"
+	)
+
 	createBucket(t, s3client, bucket)
+	putFile(t, s3client, bucket, filename, "content")
 
-	sourceWorkDir := fs.NewFile(t, "source.go")
-	srcpath := filepath.ToSlash(sourceWorkDir.Path())
-	dstpath := fmt.Sprintf("s3://%s/", bucket)
+	srcpath := fmt.Sprintf("s3://%s/%s", bucket, filename)
 
-	cmd := s5cmd("sync", srcpath, dstpath)
+	cmd := s5cmd("sync", srcpath, ".")
 	result := icmd.RunCmd(cmd)
+	result.Assert(t, icmd.Success)
 
-	result.Assert(t, icmd.Expected{ExitCode: 1})
-
-	assertLines(t, result.Stderr(), map[int]compareFunc{
-		0: equals(`ERROR "sync %s %s": local source must be a directory`, srcpath, dstpath),
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(`cp %v %v`, srcpath, filename),
 	})
+
+	// rerunning same command should not download object, empty result expected
+	result = icmd.RunCmd(cmd)
+	result.Assert(t, icmd.Success)
+	assertLines(t, result.Stdout(), map[int]compareFunc{})
 }
 
-// sync s3://bucket/source.go .
-func TestSyncSingleS3ObjectToLocalNotPermitted(t *testing.T) {
+// sync file s3://bucket
+func TestSyncLocalFileToS3Twice(t *testing.T) {
 	t.Parallel()
 
+	bucket := s3BucketFromTestName(t)
+
 	s3client, s5cmd, cleanup := setup(t)
 	defer cleanup()
 
 	const (
-		filename = "source.go"
-		bucket   = "bucket"
+		filename = "testfile1.txt"
+		content  = "this is the content"
 	)
+	createBucket(t, s3client, bucket)
+
+	// the file to be uploaded is modified
+	workdir := fs.NewDir(t, t.Name(), fs.WithFile(filename, content))
+	defer workdir.Remove()
+
+	dstpath := fmt.Sprintf("s3://%v", bucket)
+
+	cmd := s5cmd("sync", filename, dstpath)
+	result := icmd.RunCmd(cmd, withWorkingDir(workdir))
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(`cp %v %v/%v`, filename, dstpath, filename),
+	})
+
+	// rerunning same command should not upload files, empty result expected
+	result = icmd.RunCmd(cmd, withWorkingDir(workdir))
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{})
+}
+
+// sync --raw object* s3://bucket/prefix/
+func TestCopyLocalFilestoS3WithRawFlag(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip()
+	}
+
+	t.Parallel()
+
+	bucket := s3BucketFromTestName(t)
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
 
 	createBucket(t, s3client, bucket)
-	putFile(t, s3client, bucket, filename, "content")
 
-	srcpath := fmt.Sprintf("s3://%s/%s", bucket, filename)
+	files := []fs.PathOp{
+		fs.WithFile("file*.txt", "content"),
+		fs.WithFile("file*1.txt", "content"),
+		fs.WithFile("file*file.txt", "content"),
+		fs.WithFile("file*2.txt", "content"),
+	}
 
-	cmd := s5cmd("sync", srcpath, ".")
-	result := icmd.RunCmd(cmd)
-	result.Assert(t, icmd.Expected{ExitCode: 1})
+	expectedFiles := []string{"file*.txt"}
+	nonExpectedFiles := []string{"file*1.txt", "file*file.txt", "file*2.txt"}
 
-	assertLines(t, result.Stderr(), map[int]compareFunc{
-		0: equals(`ERROR "sync %s .": remote source %q must be a bucket or a prefix`, srcpath, srcpath),
+	// the file to be uploaded is modified
+	workdir := fs.NewDir(t, t.Name(), files...)
+	defer workdir.Remove()
+
+	dstpath := fmt.Sprintf("s3://%v/prefix/", bucket)
+
+	cmd := s5cmd("sync", "--raw", "file*.txt", dstpath)
+	result := icmd.RunCmd(cmd, withWorkingDir(workdir))
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(`cp file*.txt %vfile*.txt`, dstpath),
 	})
+
+	result = icmd.RunCmd(cmd, withWorkingDir(workdir))
+
+	// second run should not upload files, empty result expected
+	result.Assert(t, icmd.Success)
+	assertLines(t, result.Stdout(), map[int]compareFunc{})
+
+	for _, obj := range expectedFiles {
+		err := ensureS3Object(s3client, bucket, "prefix/"+obj, "content")
+		if err != nil {
+			t.Fatalf("%s is not exist in s3\n", obj)
+		}
+	}
+
+	for _, obj := range nonExpectedFiles {
+		err := ensureS3Object(s3client, bucket, "prefix/"+obj, "content")
+		assertError(t, err, errS3NoSuchKey)
+	}
 }
 
 // sync folder/ s3://bucket
@@ -415,6 +521,7 @@ func TestSyncLocalFolderToS3BucketSourceNewer(t *testing.T) {
 // sync s3://bucket/* folder/ (same objects, source older)
 func TestSyncS3BucketToLocalFolderSameObjectsSourceOlder(t *testing.T) {
 	t.Parallel()
+
 	now := time.Now()
 	timeSource := newFixedTimeSource(now)
 	s3client, s5cmd, cleanup := setup(t, withTimeSource(timeSource))
@@ -495,6 +602,7 @@ func TestSyncS3BucketToLocalFolderSameObjectsSourceOlder(t *testing.T) {
 // sync s3://bucket/* folder/ (same objects, source newer)
 func TestSyncS3BucketToLocalFolderSameObjectsSourceNewer(t *testing.T) {
 	t.Parallel()
+
 	now := time.Now()
 	timeSource := newFixedTimeSource(now)
 	s3client, s5cmd, cleanup := setup(t, withTimeSource(timeSource))
@@ -572,6 +680,7 @@ func TestSyncS3BucketToLocalFolderSameObjectsSourceNewer(t *testing.T) {
 // sync s3://bucket/* s3://destbucket/ (source newer, same objects, different content, same sizes)
 func TestSyncS3BucketToS3BucketSameSizesSourceNewer(t *testing.T) {
 	t.Parallel()
+
 	now := time.Now()
 	timeSource := newFixedTimeSource(now)
 	s3client, s5cmd, cleanup := setup(t, withTimeSource(timeSource))
@@ -640,6 +749,7 @@ func TestSyncS3BucketToS3BucketSameSizesSourceNewer(t *testing.T) {
 // sync s3://bucket/* s3://destbucket/ (source older, same objects, different content, same sizes)
 func TestSyncS3BucketToS3BucketSameSizesSourceOlder(t *testing.T) {
 	t.Parallel()
+
 	now := time.Now()
 	timeSource := newFixedTimeSource(now)
 	s3client, s5cmd, cleanup := setup(t, withTimeSource(timeSource))
@@ -784,6 +894,7 @@ func TestSyncS3BucketToLocalFolderSameObjectsSizeOnly(t *testing.T) {
 // sync --size-only folder/ s3://bucket/
 func TestSyncLocalFolderToS3BucketSameObjectsSizeOnly(t *testing.T) {
 	t.Parallel()
+
 	s3client, s5cmd, cleanup := setup(t)
 	defer cleanup()
 
@@ -857,6 +968,7 @@ func TestSyncLocalFolderToS3BucketSameObjectsSizeOnly(t *testing.T) {
 // sync --size-only s3://bucket/* s3://destbucket/
 func TestSyncS3BucketToS3BucketSizeOnly(t *testing.T) {
 	t.Parallel()
+
 	now := time.Now()
 	timeSource := newFixedTimeSource(now)
 	s3client, s5cmd, cleanup := setup(t, withTimeSource(timeSource))
@@ -990,6 +1102,7 @@ func TestSyncS3BucketToLocalWithDelete(t *testing.T) {
 // sync --delete folder/ s3://bucket/*
 func TestSyncLocalToS3BucketWithDelete(t *testing.T) {
 	t.Parallel()
+
 	now := time.Now()
 	s3client, s5cmd, cleanup := setup(t)
 	defer cleanup()
@@ -1059,6 +1172,7 @@ func TestSyncLocalToS3BucketWithDelete(t *testing.T) {
 // sync --delete s3://bucket/* s3://destbucket/
 func TestSyncS3BucketToS3BucketWithDelete(t *testing.T) {
 	t.Parallel()
+
 	s3client, s5cmd, cleanup := setup(t)
 	defer cleanup()
 
@@ -1205,6 +1319,7 @@ func TestSyncS3toLocalWithWildcard(t *testing.T) {
 // sync --delete s3://bucket/* .
 func TestSyncS3BucketToLocalWithDeleteFlag(t *testing.T) {
 	t.Parallel()
+
 	s3client, s5cmd, cleanup := setup(t)
 	defer cleanup()
 
@@ -1251,3 +1366,258 @@ func TestSyncS3BucketToLocalWithDeleteFlag(t *testing.T) {
 	expected := fs.Expected(t, expectedLayout...)
 	assert.Assert(t, fs.Equal(workdir.Path(), expected))
 }
+
+// sync dir/ s3://bucket (symlink)
+func TestSyncLocalFilesWithSymlinksToS3Bucket(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	fileContent := "CAFEBABE"
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("file1.txt", fileContent),
+			fs.WithFile("file2.txt", fileContent),
+		),
+		fs.WithDir("b"),
+		fs.WithSymlink("b/link1", "a/file1.txt"),
+		fs.WithSymlink("b/link2", "a/file2.txt"),
+	}
+
+	workdir := fs.NewDir(t, "somedir", folderLayout...)
+	defer workdir.Remove()
+
+	src := fmt.Sprintf("%v/b", workdir.Path())
+	src = filepath.ToSlash(src)
+	dst := fmt.Sprintf("s3://%v/", bucket)
+
+	cmd := s5cmd("sync", src, dst)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(`cp %v/b/link1 %vb/link1`, filepath.ToSlash(workdir.Path()), dst),
+		1: equals(`cp %v/b/link2 %vb/link2`, filepath.ToSlash(workdir.Path()), dst),
+	}, sortInput(true))
+}
+
+// sync --no-follow-symlinks * s3://bucket/prefix/
+func TestSyncLocalFilesWithNoFollowSymlinksToS3Bucket(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	fileContent := "CAFEBABE"
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("file1.txt", fileContent),
+			fs.WithFile("file2.txt", fileContent),
+		),
+		fs.WithDir("b"),
+		fs.WithSymlink("b/link1", "a/file1.txt"),
+		fs.WithSymlink("b/link2", "a/file2.txt"),
+	}
+
+	workdir := fs.NewDir(t, "somedir", folderLayout...)
+	defer workdir.Remove()
+
+	src := fmt.Sprintf("%v/b", workdir.Path())
+	src = filepath.ToSlash(src)
+	dst := fmt.Sprintf("s3://%v/", bucket)
+
+	cmd := s5cmd("sync", "--no-follow-symlinks", src, dst)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	// do not follow symlinks in directory b (empty result)
+	assertLines(t, result.Stdout(), map[int]compareFunc{})
+}
+
+// sync --exclude pattern s3://bucket/* s3://anotherbucket/prefix/
+func TestSyncS3ObjectsIntoAnotherBucketWithExcludeFilters(t *testing.T) {
+	t.Parallel()
+
+	const (
+		srcbucket = "bucket"
+		dstbucket = "dstbucket"
+	)
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	createBucket(t, s3client, srcbucket)
+	createBucket(t, s3client, dstbucket)
+
+	srcFiles := []string{
+		"file_already_exists_in_destination.txt",
+		"file_not_exists_in_destination.txt",
+		"main.py",
+		"main.js",
+		"readme.md",
+		"main.pdf",
+		"main/file.txt",
+	}
+
+	dstFiles := []string{
+		"prefix/file_already_exists_in_destination.txt",
+	}
+
+	expectedFiles := []string{
+		"prefix/file_not_exists_in_destination.txt",
+		"prefix/file_already_exists_in_destination.txt",
+	}
+
+	excludedFiles := []string{
+		"main.py",
+		"main.js",
+		"main.pdf",
+		"main/file.txt",
+		"readme.md",
+	}
+
+	const (
+		content         = "this is a file content"
+		excludePattern1 = "main*"
+		excludePattern2 = "*.md"
+	)
+
+	for _, filename := range srcFiles {
+		putFile(t, s3client, srcbucket, filename, content)
+	}
+
+	for _, filename := range dstFiles {
+		putFile(t, s3client, dstbucket, filename, content)
+	}
+
+	src := fmt.Sprintf("s3://%v/*", srcbucket)
+	dst := fmt.Sprintf("s3://%v/prefix/", dstbucket)
+
+	cmd := s5cmd("sync", "--exclude", excludePattern1, "--exclude", excludePattern2, src, dst)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(`cp s3://%s/file_not_exists_in_destination.txt s3://%s/prefix/file_not_exists_in_destination.txt`, srcbucket, dstbucket),
+	}, sortInput(true))
+
+	// assert s3 source objects
+	for _, filename := range srcFiles {
+		assert.Assert(t, ensureS3Object(s3client, srcbucket, filename, content))
+	}
+
+	// assert s3 destination objects
+	for _, filename := range expectedFiles {
+		assert.Assert(t, ensureS3Object(s3client, dstbucket, filename, content))
+	}
+
+	// assert s3 destination objects which should not be in bucket.
+	for _, filename := range excludedFiles {
+		err := ensureS3Object(s3client, dstbucket, filename, content)
+		assertError(t, err, errS3NoSuchKey)
+	}
+}
+
+// sync --exclude "*.gz" dir s3://bucket/
+// sync --exclude "*.gz" dir/ s3://bucket/
+// sync --exclude "*.gz" dir/* s3://bucket/
+func TestSyncLocalDirectoryToS3WithExcludeFilter(t *testing.T) {
+	t.Parallel()
+
+	testcases := []struct {
+		name            string
+		directoryPrefix string
+	}{
+		{
+			name:            "folder without /",
+			directoryPrefix: "",
+		},
+		{
+			name:            "folder with /",
+			directoryPrefix: "/",
+		},
+		{
+			name:            "folder with / and glob *",
+			directoryPrefix: "/*",
+		},
+	}
+
+	for _, tc := range testcases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			bucket := "testbucket"
+
+			s3client, s5cmd, cleanup := setup(t)
+			defer cleanup()
+
+			createBucket(t, s3client, bucket)
+
+			folderLayout := []fs.PathOp{
+				fs.WithFile("testfile1.txt", "this is a test file 1"),
+				fs.WithFile("readme.md", "this is a readme file"),
+				fs.WithDir(
+					"a",
+					fs.WithFile("another_test_file.txt", "yet another txt file. yatf."),
+				),
+				fs.WithDir(
+					"b",
+					fs.WithFile("filename-with-hypen.gz", "file has hypen in its name"),
+				),
+			}
+
+			workdir := fs.NewDir(t, "somedir", folderLayout...)
+			defer workdir.Remove()
+
+			const excludePattern = "*.gz"
+
+			src := fmt.Sprintf("%v/", workdir.Path())
+			src = src + tc.directoryPrefix
+			dst := fmt.Sprintf("s3://%v/prefix/", bucket)
+
+			src = filepath.ToSlash(src)
+			cmd := s5cmd("sync", "--exclude", excludePattern, src, dst)
+			result := icmd.RunCmd(cmd)
+
+			result.Assert(t, icmd.Success)
+
+			// assert local filesystem
+			expected := fs.Expected(t, folderLayout...)
+			assert.Assert(t, fs.Equal(workdir.Path(), expected))
+
+			expectedS3Content := map[string]string{
+				"prefix/testfile1.txt":           "this is a test file 1",
+				"prefix/readme.md":               "this is a readme file",
+				"prefix/a/another_test_file.txt": "yet another txt file. yatf.",
+			}
+
+			nonExpectedS3Content := map[string]string{
+				"prefix/b/filename-with-hypen.gz": "file has hypen in its name",
+			}
+
+			// assert objects should be in S3
+			for key, content := range expectedS3Content {
+				assert.Assert(t, ensureS3Object(s3client, bucket, key, content))
+			}
+
+			//assert objects should not be in S3.
+			for key, content := range nonExpectedS3Content {
+				err := ensureS3Object(s3client, bucket, key, content)
+				assertError(t, err, errS3NoSuchKey)
+			}
+		})
+	}
+}