Repository: peak/s5cmd (go)

Issue:
Invalid symbolic link to directory with --parents
Tested with `s5cmd 0.6.0`

When using `--parents` parameter with `cp`, the symbolic link to a directory is managed as file

```
$ find tests -ls
  3318055      4 drwxrwxr-x   3 murlock  murlock      4096 Jun 17 11:59 test/
  3282596      0 lrwxrwxrwx   1 murlock  murlock        10 Jun 17 11:59 test/toto -> tata/160MB
  3282588      0 lrwxrwxrwx   1 murlock  murlock         4 Jun 17 11:59 test/tutu -> tata
  3281209      4 -rw-r--r--   1 murlock  murlock       111 Jun 17 11:58 test/magic
  3318056      4 drwxrwxr-x   2 murlock  murlock      4096 Jun 17 11:58 test/tata
  3281650      4 -rw-r--r--   1 murlock  murlock       111 Jun 17 11:58 test/tata/magic
  3282576 163844 -rw-rw-r--   1 murlock  murlock  167772160 Jun 17 11:58 test/tata/160MB
$ s5cmd  -endpoint-url http://127.0.0.1:5000 -us 5  cp --parents test/ s3://s5cmd/test2/
                    # Uploading magic... (111 bytes)
                    # Uploading 160MB... (167772160 bytes)
                    # Uploading magic... (111 bytes)
                    # Uploading toto... (167772160 bytes)
                    # Uploading tutu... (4096 bytes)
                    + "cp --parents test/magic s3://s5cmd/test2/magic"
                    + "cp --parents test/tata/magic s3://s5cmd/test2/tata/magic"
                    - "cp --parents test/tutu s3://s5cmd/test2/tutu": MultipartUpload: upload multipart failed upload id: ODgyNzQyZmUtNzkzYy00ZGNjLThjN2MtOGZkODRmMTdiOTJl caused by: EntityTooLarge: Your proposed upload exceeds the maximum allowed object size. status code: 400, request id: tx9cb2eb104ddb4fa0a26f2-005d079de3, host id: tx9cb2eb104ddb4fa0a26f2-005d079de3
                    + "cp --parents test/toto s3://s5cmd/test2/toto"
                    + "cp --parents test/tata/160MB s3://s5cmd/test2/tata/160MB"
2019/06/17 16:04:23 -ERR "cp test/ s3://s5cmd/test2/": Not all jobs completed successfully: 4/5
2019/06/17 16:04:24 # All workers idle, finishing up...
```

A strace show that symbolic link `tutu` was treated as file:
```
10057 newfstatat(AT_FDCWD, "test/tutu", {st_mode=S_IFDIR|0775, st_size=4096, ...}, 0) = 0                                           
10057 openat(AT_FDCWD, "test/tutu", O_RDONLY|O_CLOEXEC <unfinished ...>                                                             
10059 <... connect resumed> )           = -1 EINPROGRESS (Operation now in progress)                                                
10057 <... openat resumed> )            = 10      
10057 lseek(10, 0, SEEK_CUR)            = 0                                                                                         
10057 lseek(10, 0, SEEK_END)            = 9223372036854775807  
10061 pread64(10, 0xc00031c000, 32768, 1844674407370956) = -1 EISDIR (Is a directory)  
```

and network trace show the result of `lseek` used as-is:
```
    PUT /s5cmd/test2/tutu?partNumber=1&uploadId=NjEwZTE3MTYtMmUxNC00N2E4LWFjODgtOTFlMzYwYTM3NmJk HTTP/1.1\r\n
    Content-Length: 922337203685478\r\n
```

Pull request description:
Follow symbolic links
- resolves #17 
- added `--no-follow-symlinks` flag to cp command.
- by default s5cmd follows symbolic links

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c543c6..00c0c5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,8 +30,10 @@ This is a major release with many breaking changes.
 - `-ds`, `-dw`, `-us` and `-uw` global flags are no longer available. Multipart
   concurrency and part size flags are now part of the `cp/mv` command. New
   replacement flags are `--concurrency | -c` and `--part-size | -p`. ([#110](https://github.com/peak/s5cmd/pull/110))
+- s5cmd `cp` command follows symbolic links by default (only when uploading to s3 from local filesystem). Use `--no-follow-symlinks` flag to disable this feature.
 - Dropped `-parents` flag from copy command. Copy behaviour has changed to preserve the directory hierarchy as a default.
   Optional `-flatten` flag is added to flatten directory structure.
+
 #### Features
 
 - Added `mb` command to make buckets. ([#25](https://github.com/peak/s5cmd/issues/25))
diff --git a/command/cp.go b/command/cp.go
index 9806518..05c121c 100644
--- a/command/cp.go
+++ b/command/cp.go
@@ -81,6 +81,10 @@ var copyCommandFlags = []cli.Flag{
 		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",
+	},
 	&cli.StringFlag{
 		Name:  "storage-class",
 		Usage: "set storage class for target ('STANDARD','REDUCED_REDUNDANCY','GLACIER','STANDARD_IA')",
@@ -116,15 +120,15 @@ var CopyCommand = &cli.Command{
 			fullCommand:  givenCommand(c),
 			deleteSource: false, // don't delete source
 			// flags
-			noClobber:     c.Bool("no-clobber"),
-			ifSizeDiffer:  c.Bool("if-size-differ"),
-			ifSourceNewer: c.Bool("if-source-newer"),
-			flatten:       c.Bool("flatten"),
-			storageClass:  storage.LookupClass(c.String("storage-class")),
-			concurrency:   c.Int("concurrency"),
-			partSize:      c.Int64("part-size") * megabytes,
+			noClobber:      c.Bool("no-clobber"),
+			ifSizeDiffer:   c.Bool("if-size-differ"),
+			ifSourceNewer:  c.Bool("if-source-newer"),
+			flatten:        c.Bool("flatten"),
+			followSymlinks: !c.Bool("no-follow-symlinks"),
+			storageClass:   storage.LookupClass(c.String("storage-class")),
+			concurrency:    c.Int("concurrency"),
+			partSize:       c.Int64("part-size") * megabytes,
 		}
-
 		return copyCommand.Run(c.Context)
 	},
 }
@@ -138,11 +142,12 @@ type Copy struct {
 	deleteSource bool
 
 	// flags
-	noClobber     bool
-	ifSizeDiffer  bool
-	ifSourceNewer bool
-	flatten       bool
-	storageClass  storage.StorageClass
+	noClobber      bool
+	ifSizeDiffer   bool
+	ifSourceNewer  bool
+	flatten        bool
+	followSymlinks bool
+	storageClass   storage.StorageClass
 
 	// s3 options
 	concurrency int
@@ -165,7 +170,7 @@ func (c Copy) Run(ctx context.Context) error {
 		return err
 	}
 
-	objch, err := expandSource(ctx, client, srcurl)
+	objch, err := expandSource(ctx, client, c.followSymlinks, srcurl)
 	if err != nil {
 		return err
 	}
diff --git a/command/du.go b/command/du.go
index f3aaab0..a8b82c6 100644
--- a/command/du.go
+++ b/command/du.go
@@ -90,7 +90,7 @@ func Size(
 
 	var merror error
 
-	for object := range client.List(ctx, srcurl) {
+	for object := range client.List(ctx, srcurl, false) {
 		if object.Type.IsDir() || errorpkg.IsCancelation(object.Err) {
 			continue
 		}
diff --git a/command/expand.go b/command/expand.go
index b18c19b..d3467f6 100644
--- a/command/expand.go
+++ b/command/expand.go
@@ -14,6 +14,7 @@ import (
 func expandSource(
 	ctx context.Context,
 	client storage.Storage,
+	followSymlinks bool,
 	srcurl *url.URL,
 ) (<-chan *storage.Object, error) {
 	var isDir bool
@@ -30,11 +31,13 @@ func expandSource(
 
 	// call storage.List for only walking operations.
 	if srcurl.HasGlob() || isDir {
-		return client.List(ctx, srcurl), nil
+		return client.List(ctx, srcurl, followSymlinks), nil
 	}
 
 	ch := make(chan *storage.Object, 1)
-	ch <- &storage.Object{URL: srcurl}
+	if storage.ShouldProcessUrl(srcurl, followSymlinks) {
+		ch <- &storage.Object{URL: srcurl}
+	}
 	close(ch)
 	return ch, nil
 }
@@ -46,6 +49,7 @@ func expandSource(
 func expandSources(
 	ctx context.Context,
 	client storage.Storage,
+	followSymlinks bool,
 	srcurls ...*url.URL,
 ) <-chan *storage.Object {
 	ch := make(chan *storage.Object)
@@ -61,7 +65,7 @@ func expandSources(
 			go func(origSrc *url.URL) {
 				defer wg.Done()
 
-				objch, err := expandSource(ctx, client, origSrc)
+				objch, err := expandSource(ctx, client, followSymlinks, origSrc)
 				if err != nil {
 					ch <- &storage.Object{Err: err}
 					return
diff --git a/command/ls.go b/command/ls.go
index ffbd7b1..a7980b5 100644
--- a/command/ls.go
+++ b/command/ls.go
@@ -116,7 +116,7 @@ func List(
 
 	var merror error
 
-	for object := range client.List(ctx, srcurl) {
+	for object := range client.List(ctx, srcurl, false) {
 		if errorpkg.IsCancelation(object.Err) {
 			continue
 		}
diff --git a/command/rm.go b/command/rm.go
index ff46d9b..64e41b8 100644
--- a/command/rm.go
+++ b/command/rm.go
@@ -75,7 +75,7 @@ func Delete(
 		return err
 	}
 
-	objChan := expandSources(ctx, client, srcurls...)
+	objChan := expandSources(ctx, client, false, srcurls...)
 
 	// do object->url transformation
 	urlch := make(chan *url.URL)
diff --git a/storage/fs.go b/storage/fs.go
index aa65c5a..fe6f22c 100644
--- a/storage/fs.go
+++ b/storage/fs.go
@@ -38,16 +38,16 @@ func (f *Filesystem) Stat(ctx context.Context, url *url.URL) (*Object, error) {
 	}, nil
 }
 
-func (f *Filesystem) List(ctx context.Context, src *url.URL) <-chan *Object {
+func (f *Filesystem) List(ctx context.Context, src *url.URL, followSymlinks bool) <-chan *Object {
 	obj, err := f.Stat(ctx, src)
 	isDir := err == nil && obj.Type.IsDir()
 
 	if isDir {
-		return f.walkDir(ctx, src)
+		return f.walkDir(ctx, src, followSymlinks)
 	}
 
 	if src.HasGlob() {
-		return f.expandGlob(ctx, src)
+		return f.expandGlob(ctx, src, followSymlinks)
 	}
 
 	return f.listSingleObject(ctx, src)
@@ -65,7 +65,7 @@ func (f *Filesystem) listSingleObject(ctx context.Context, src *url.URL) <-chan
 	return ch
 }
 
-func (f *Filesystem) expandGlob(ctx context.Context, src *url.URL) <-chan *Object {
+func (f *Filesystem) expandGlob(ctx context.Context, src *url.URL, followSymlinks bool) <-chan *Object {
 	ch := make(chan *Object)
 
 	go func() {
@@ -95,7 +95,7 @@ func (f *Filesystem) expandGlob(ctx context.Context, src *url.URL) <-chan *Objec
 				continue
 			}
 
-			walkDir(ctx, f, fileurl, func(obj *Object) {
+			walkDir(ctx, f, fileurl, followSymlinks, func(obj *Object) {
 				sendObject(ctx, obj, ch)
 			})
 		}
@@ -103,7 +103,11 @@ func (f *Filesystem) expandGlob(ctx context.Context, src *url.URL) <-chan *Objec
 	return ch
 }
 
-func walkDir(ctx context.Context, storage Storage, src *url.URL, fn func(o *Object)) {
+func walkDir(ctx context.Context, storage Storage, src *url.URL, followSymlinks bool, fn func(o *Object)) {
+	//skip if symlink is pointing to a dir and --no-follow-symlink
+	if !ShouldProcessUrl(src, followSymlinks) {
+		return
+	}
 	err := godirwalk.Walk(src.Absolute(), &godirwalk.Options{
 		Callback: func(pathname string, dirent *godirwalk.Dirent) error {
 			// we're interested in files
@@ -118,16 +122,21 @@ func walkDir(ctx context.Context, storage Storage, src *url.URL, fn func(o *Obje
 
 			fileurl.SetRelative(src.Absolute())
 
+			//skip if symlink is pointing to a file and --no-follow-symlink
+			if !ShouldProcessUrl(fileurl, followSymlinks) {
+				return nil
+			}
+
 			obj, err := storage.Stat(ctx, fileurl)
+
 			if err != nil {
 				return err
 			}
 			fn(obj)
 			return nil
 		},
-		// TODO(ig): enable following symlink once we have the necessary cli
 		// flags
-		FollowSymbolicLinks: false,
+		FollowSymbolicLinks: followSymlinks,
 	})
 	if err != nil {
 		obj := &Object{Err: err}
@@ -135,12 +144,12 @@ func walkDir(ctx context.Context, storage Storage, src *url.URL, fn func(o *Obje
 	}
 }
 
-func (f *Filesystem) walkDir(ctx context.Context, src *url.URL) <-chan *Object {
+func (f *Filesystem) walkDir(ctx context.Context, src *url.URL, followSymlinks bool) <-chan *Object {
 	ch := make(chan *Object)
 	go func() {
 		defer close(ch)
 
-		walkDir(ctx, f, src, func(obj *Object) {
+		walkDir(ctx, f, src, followSymlinks, func(obj *Object) {
 			sendObject(ctx, obj, ch)
 		})
 	}()
diff --git a/storage/mock_storage.go b/storage/mock_storage.go
index 3abf7cb..158188a 100644
--- a/storage/mock_storage.go
+++ b/storage/mock_storage.go
@@ -2,14 +2,10 @@
 
 package storage
 
-import (
-	context "context"
-	io "io"
-
-	mock "github.com/stretchr/testify/mock"
-
-	url "github.com/peak/s5cmd/storage/url"
-)
+import context "context"
+import io "io"
+import mock "github.com/stretchr/testify/mock"
+import url "github.com/peak/s5cmd/storage/url"
 
 // MockStorage is an autogenerated mock type for the Storage type
 type MockStorage struct {
@@ -65,13 +61,13 @@ func (_m *MockStorage) Get(ctx context.Context, src *url.URL, dst io.WriterAt, c
 	return r0, r1
 }
 
-// List provides a mock function with given fields: ctx, src
-func (_m *MockStorage) List(ctx context.Context, src *url.URL) <-chan *Object {
-	ret := _m.Called(ctx, src)
+// List provides a mock function with given fields: ctx, src, followSymlinks
+func (_m *MockStorage) List(ctx context.Context, src *url.URL, followSymlinks bool) <-chan *Object {
+	ret := _m.Called(ctx, src, followSymlinks)
 
 	var r0 <-chan *Object
-	if rf, ok := ret.Get(0).(func(context.Context, *url.URL) <-chan *Object); ok {
-		r0 = rf(ctx, src)
+	if rf, ok := ret.Get(0).(func(context.Context, *url.URL, bool) <-chan *Object); ok {
+		r0 = rf(ctx, src, followSymlinks)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(<-chan *Object)
diff --git a/storage/s3.go b/storage/s3.go
index 6836a1c..9e01815 100644
--- a/storage/s3.go
+++ b/storage/s3.go
@@ -161,7 +161,7 @@ func (s *S3) Stat(ctx context.Context, url *url.URL) (*Object, error) {
 // List is a non-blocking S3 list operation which paginates and filters S3
 // keys. If no object found or an error is encountered during this period,
 // it sends these errors to object channel.
-func (s *S3) List(ctx context.Context, url *url.URL) <-chan *Object {
+func (s *S3) List(ctx context.Context, url *url.URL, _ bool) <-chan *Object {
 	if isGoogleEndpoint(s.endpointURL) {
 		return s.listObjects(ctx, url)
 	}
diff --git a/storage/storage.go b/storage/storage.go
index e6f3a8c..3c65bc0 100644
--- a/storage/storage.go
+++ b/storage/storage.go
@@ -28,7 +28,7 @@ type Storage interface {
 	Stat(ctx context.Context, src *url.URL) (*Object, error)
 
 	// List the objects and directories/prefixes in the src.
-	List(ctx context.Context, src *url.URL) <-chan *Object
+	List(ctx context.Context, src *url.URL, followSymlinks bool) <-chan *Object
 
 	// Copy src to dst, optionally setting the given metadata. Src and dst
 	// arguments are of the same type. If src is a remote type, server side
@@ -114,6 +114,31 @@ func (o ObjectType) IsDir() bool {
 	return o.mode.IsDir()
 }
 
+// IsSymlink checks if the object is a symbolic link.
+func (o ObjectType) IsSymlink() bool {
+	return o.mode&os.ModeSymlink != 0
+}
+
+// ShouldProcessUrl returns true if follow symlinks is enabled.
+// If follow symlinks is disabled we should not process the url.
+// (this check is needed only for local files)
+func ShouldProcessUrl(url *url.URL, followSymlinks bool) bool {
+	if followSymlinks {
+		return true
+	}
+
+	if url.IsRemote() {
+		return true
+	}
+	fi, err := os.Lstat(url.Absolute())
+	if err != nil {
+		return false
+	}
+
+	// do not process symlinks
+	return fi.Mode()&os.ModeSymlink == 0
+}
+
 // dateFormat is a constant time template for the bucket.
 const dateFormat = "2006/01/02 15:04:05"
 


Test patch:
diff --git a/command/expand_test.go b/command/expand_test.go
index 8d6ea4b..f64ca14 100644
--- a/command/expand_test.go
+++ b/command/expand_test.go
@@ -6,6 +6,9 @@ import (
 	"sort"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
+	"gotest.tools/v3/fs"
+
 	"github.com/stretchr/testify/mock"
 
 	"github.com/peak/s5cmd/storage"
@@ -148,11 +151,11 @@ func TestExpandSources(t *testing.T) {
 				ch := generateObjects(objects)
 
 				if src != "s3://bucket/key" {
-					client.On("List", mock.Anything, srcurl).Once().Return(ch)
+					client.On("List", mock.Anything, srcurl, mock.Anything).Once().Return(ch)
 				}
 			}
 
-			gotChan := expandSources(context.Background(), client, srcurls...)
+			gotChan := expandSources(context.Background(), client, false, srcurls...)
 
 			var objects []string
 			for obj := range gotChan {
@@ -175,6 +178,153 @@ func TestExpandSources(t *testing.T) {
 	}
 }
 
+func TestExpandSource_Follow_Link_To_Single_File(t *testing.T) {
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", ""),
+		),
+		fs.WithDir(
+			"b",
+		),
+		fs.WithSymlink("b/my_link", "a/f1.txt"),
+	}
+
+	workdir := fs.NewDir(t, "expandsourcetest", folderLayout...)
+	defer workdir.Remove()
+
+	ctx := context.Background()
+	workdirUrl, _ := url.New(workdir.Join("b/my_link"))
+
+	//follow symbolic links
+	ch, _ := expandSource(ctx, storage.NewFilesystem(), true, workdirUrl)
+	var expected []string
+	for obj := range ch {
+		expected = append(expected, obj.URL.Absolute())
+	}
+	assert.Equal(t, []string{workdir.Join("b/my_link")}, expected)
+}
+
+func TestExpandSource_Do_Not_Follow_Link_To_Single_File(t *testing.T) {
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", ""),
+		),
+		fs.WithDir(
+			"b",
+		),
+		fs.WithSymlink("b/my_link", "a/f1.txt"),
+	}
+
+	workdir := fs.NewDir(t, "expandsourcetest", folderLayout...)
+	defer workdir.Remove()
+
+	ctx := context.Background()
+	workdirUrl, _ := url.New(workdir.Join("b/my_link"))
+
+	//do not follow symbolic links
+	ch, _ := expandSource(ctx, storage.NewFilesystem(), false, workdirUrl)
+	var expected []string
+	for obj := range ch {
+		expected = append(expected, obj.URL.Absolute())
+	}
+	assert.Empty(t, expected)
+}
+
+func TestExpandSource_Follow_Link_To_Directory(t *testing.T) {
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", ""),
+			fs.WithFile("f2.txt", ""),
+			fs.WithDir("b",
+				fs.WithFile("f3.txt", "")),
+		),
+		fs.WithDir(
+			"c",
+		),
+		fs.WithSymlink("c/my_link", "a"),
+	}
+
+	workdir := fs.NewDir(t, "expandsourcetest", folderLayout...)
+	defer workdir.Remove()
+
+	ctx := context.Background()
+	workdirUrl, _ := url.New(workdir.Join("c/my_link"))
+
+	//follow symbolic links
+	ch, _ := expandSource(ctx, storage.NewFilesystem(), true, workdirUrl)
+	var expected []string
+	for obj := range ch {
+		expected = append(expected, obj.URL.Absolute())
+	}
+	sort.Strings(expected)
+	assert.Equal(t, []string{
+		workdir.Join("c/my_link/b/f3.txt"),
+		workdir.Join("c/my_link/f1.txt"),
+		workdir.Join("c/my_link/f2.txt"),
+	}, expected)
+}
+
+func TestExpandSource_Do_Not_Follow_Link_To_Directory(t *testing.T) {
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", ""),
+			fs.WithFile("f2.txt", ""),
+			fs.WithDir("b",
+				fs.WithFile("f3.txt", "")),
+		),
+		fs.WithDir(
+			"c",
+		),
+		fs.WithSymlink("c/my_link", "a"),
+	}
+
+	workdir := fs.NewDir(t, "expandsourcetest", folderLayout...)
+	defer workdir.Remove()
+
+	ctx := context.Background()
+	workdirUrl, _ := url.New(workdir.Join("c/my_link"))
+
+	//do not follow symbolic links
+	ch, _ := expandSource(ctx, storage.NewFilesystem(), false, workdirUrl)
+	var expected []string
+	for obj := range ch {
+		expected = append(expected, obj.URL.Absolute())
+	}
+	assert.Empty(t, expected)
+}
+
+func TestExpandSource_Do_Not_Follow_Symlinks(t *testing.T) {
+	ctx := context.Background()
+	fileContent := "CAFEBABE"
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", fileContent),
+		),
+		fs.WithDir("b"),
+		fs.WithDir("c"),
+		fs.WithSymlink("b/link1", "a/f1.txt"),
+		fs.WithSymlink("c/link2", "b/link1"),
+	}
+
+	workdir := fs.NewDir(t, t.Name(), folderLayout...)
+	defer workdir.Remove()
+
+	workdirUrl, _ := url.New(workdir.Path())
+
+	//do not follow symbolic links
+	ch, _ := expandSource(ctx, storage.NewFilesystem(), false, workdirUrl)
+	var expected []string
+	for obj := range ch {
+		expected = append(expected, obj.URL.Absolute())
+	}
+	assert.Equal(t, []string{workdir.Join("a/f1.txt")}, expected)
+}
+
 func keys(urls map[string][]*storage.Object) []string {
 	var urlKeys []string
 	for key := range urls {
diff --git a/e2e/cp_test.go b/e2e/cp_test.go
index c6c33a7..02c3e54 100644
--- a/e2e/cp_test.go
+++ b/e2e/cp_test.go
@@ -2550,3 +2550,124 @@ func TestCopyMultipleLocalNestedFilesToS3(t *testing.T) {
 	assert.Assert(t, ensureS3Object(s3client, bucket, "prefix/a/file1.txt", "file1"))
 	assert.Assert(t, ensureS3Object(s3client, bucket, "prefix/b/c/file2.txt", "file2"))
 }
+
+// cp --no-follow-symlinks my_link s3://bucket/prefix/
+func TestCopyLinkToASingleFileWithFollowSymlinkDisabled(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	const bucket = "bucket"
+	createBucket(t, s3client, bucket)
+
+	fileContent := "CAFEBABE"
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", fileContent),
+		),
+		fs.WithDir("b"),
+		fs.WithSymlink("b/my_link", "a/f1.txt"),
+	}
+
+	workdir := fs.NewDir(t, t.Name(), folderLayout...)
+	defer workdir.Remove()
+
+	dst := fmt.Sprintf("s3://%v/prefix/", bucket)
+
+	cmd := s5cmd("cp", "--no-follow-symlinks", "b/my_link", dst)
+	result := icmd.RunCmd(cmd, withWorkingDir(workdir))
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(""),
+	}, sortInput(true))
+}
+
+// cp * s3://bucket/prefix/
+func TestCopyWithFollowSymlink(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	const bucket = "bucket"
+	createBucket(t, s3client, bucket)
+
+	fileContent := "CAFEBABE"
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", fileContent),
+		),
+		fs.WithDir("b"),
+		fs.WithDir("c"),
+		fs.WithSymlink("b/link1", "a/f1.txt"),
+		fs.WithSymlink("c/link2", "b/link1"),
+	}
+
+	workdir := fs.NewDir(t, t.Name(), folderLayout...)
+	defer workdir.Remove()
+
+	dst := fmt.Sprintf("s3://%v/prefix/", bucket)
+
+	cmd := s5cmd("cp", "*", dst)
+	result := icmd.RunCmd(cmd, withWorkingDir(workdir))
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(""),
+		1: equals("cp a/f1.txt %va/f1.txt", dst),
+		2: equals("cp b/link1 %vb/link1", dst),
+		3: equals("cp c/link2 %vc/link2", dst),
+	}, sortInput(true))
+
+	// assert s3 objects
+	assert.Assert(t, ensureS3Object(s3client, bucket, "prefix/a/f1.txt", fileContent))
+	assert.Assert(t, ensureS3Object(s3client, bucket, "prefix/b/link1", fileContent))
+	assert.Assert(t, ensureS3Object(s3client, bucket, "prefix/c/link2", fileContent))
+}
+
+// cp --no-follow-symlinks * s3://bucket/prefix/
+func TestCopyWithNoFollowSymlink(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	const bucket = "bucket"
+	createBucket(t, s3client, bucket)
+
+	fileContent := "CAFEBABE"
+	folderLayout := []fs.PathOp{
+		fs.WithDir(
+			"a",
+			fs.WithFile("f1.txt", fileContent),
+		),
+		fs.WithDir("b"),
+		fs.WithDir("c"),
+		fs.WithSymlink("b/link1", "a/f1.txt"),
+		fs.WithSymlink("c/link2", "b/link1"),
+	}
+
+	workdir := fs.NewDir(t, t.Name(), folderLayout...)
+	defer workdir.Remove()
+
+	dst := fmt.Sprintf("s3://%v/prefix/", bucket)
+
+	cmd := s5cmd("cp", "--no-follow-symlinks", "*", dst)
+	result := icmd.RunCmd(cmd, withWorkingDir(workdir))
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(""),
+		1: equals("cp a/f1.txt %va/f1.txt", dst),
+	}, sortInput(true))
+
+	// assert s3 objects
+	assert.Assert(t, ensureS3Object(s3client, bucket, "prefix/a/f1.txt", fileContent))
+}
diff --git a/storage/s3_test.go b/storage/s3_test.go
index 85a290b..54f068e 100644
--- a/storage/s3_test.go
+++ b/storage/s3_test.go
@@ -145,7 +145,7 @@ func TestS3ListSuccess(t *testing.T) {
 	}
 
 	index := 0
-	for got := range mockS3.List(context.Background(), url) {
+	for got := range mockS3.List(context.Background(), url, true) {
 		if got.Err != nil {
 			t.Errorf("unexpected error: %v", got.Err)
 			continue
@@ -184,7 +184,7 @@ func TestS3ListError(t *testing.T) {
 		r.Error = mockErr
 	})
 
-	for got := range mockS3.List(context.Background(), url) {
+	for got := range mockS3.List(context.Background(), url, true) {
 		if got.Err != mockErr {
 			t.Errorf("error got = %v, want %v", got.Err, mockErr)
 		}
@@ -220,7 +220,7 @@ func TestS3ListNoItemFound(t *testing.T) {
 		}
 	})
 
-	for got := range mockS3.List(context.Background(), url) {
+	for got := range mockS3.List(context.Background(), url, true) {
 		if got.Err != ErrNoObjectFound {
 			t.Errorf("error got = %v, want %v", got.Err, ErrNoObjectFound)
 		}
@@ -252,7 +252,7 @@ func TestS3ListContextCancelled(t *testing.T) {
 		}
 	})
 
-	for got := range mockS3.List(ctx, url) {
+	for got := range mockS3.List(ctx, url, true) {
 		reqErr, ok := got.Err.(awserr.Error)
 		if !ok {
 			t.Errorf("could not convert error")
@@ -300,7 +300,7 @@ func TestS3RetryOnInternalError(t *testing.T) {
 		retried++
 	})
 
-	for range mockS3.List(ctx, url) {
+	for range mockS3.List(ctx, url, true) {
 	}
 
 	if retried != expectedRetry {