Repository: peak/s5cmd (go)

Issue:
Feature request - Check if file exists without ls or downloading the entire object
Is it possible to check if object exists without downloading it entirely or applying ls (which requires ListObjects)
It can be implemented using - https://github.com/peak/s5cmd/blob/master/storage/s3.go#L118
Usage can be by having another op or simply --metadata_only flag for cp

Thanks ! 

Pull request description:
feat: added Head command 
This pull request adds the `head` command to the program. Closes #682.

The `head` command is designed to check if a file exists without downloading the object or bucket itself. It retrieves metadata from an object without returning the object itself. This operation is useful for users who are only interested in an object's metadata.

- Implemented the head command functionality.
- Added end-point tests to ensure the correctness of the head command implementation.

Usage:

Check if a bucket exists
```
s5cmd head s3://bucket-name
```

Check if a file exists and retrieve its metadata

```
s5cmd head s3://bucket-name/object
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec04414..80a4e04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
 
 #### Features
 - Added prefix and wildcard support to `cat` command. ([#716](https://github.com/peak/s5cmd/issues/716))
+- Added `head` command. ([#730](https://github.com/peak/s5cmd/pull/730))
 
 ## v2.2.2 - 13 Sep 2023 
 
diff --git a/README.md b/README.md
index 7b12cbf..57bd035 100644
--- a/README.md
+++ b/README.md
@@ -188,6 +188,14 @@ While executing the commands, `s5cmd` detects the region according to the follow
 
 ### Examples
 
+#### Check if a bucket exists
+
+    s5cmd head s3://bucket/
+
+#### Print a remote object's metadata
+
+    s5cmd head s3://bucket/object.gz
+
 #### Download a single S3 object
 
     s5cmd cp s3://bucket/object.gz .
diff --git a/command/app.go b/command/app.go
index f10d890..f1699e4 100644
--- a/command/app.go
+++ b/command/app.go
@@ -210,6 +210,7 @@ func Commands() []*cli.Command {
 		NewVersionCommand(),
 		NewBucketVersionCommand(),
 		NewPresignCommand(),
+		NewHeadCommand(),
 	}
 }
 
diff --git a/command/head.go b/command/head.go
new file mode 100644
index 0000000..d88be2c
--- /dev/null
+++ b/command/head.go
@@ -0,0 +1,206 @@
+package command
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/peak/s5cmd/v2/log"
+	"github.com/peak/s5cmd/v2/log/stat"
+	"github.com/peak/s5cmd/v2/storage"
+	"github.com/peak/s5cmd/v2/storage/url"
+	"github.com/peak/s5cmd/v2/strutil"
+	"github.com/urfave/cli/v2"
+)
+
+var headHelpTemplate = `Name:
+	{{.HelpName}} - {{.Usage}}
+
+Usage:
+	{{.HelpName}} [options] source
+
+Options:
+	{{range .VisibleFlags}}{{.}}
+	{{end}}
+Examples:
+	1. Print a remote object's metadata
+		 > s5cmd {{.HelpName}} s3://bucket/prefix/object
+
+	2. Check if a remote bucket exists
+		 > s5cmd {{.HelpName}} s3://bucket
+
+	3. Print a remote object's metadata with version ID
+		 > s5cmd {{.HelpName}} --version-id VERSION_ID s3://bucket/prefix/object
+
+	4. Print a remote object's metadata with raw input
+		 > s5cmd {{.HelpName}} --raw 's3://bucket/prefix/file*.txt'
+`
+
+func NewHeadCommand() *cli.Command {
+	cmd := &cli.Command{
+		Name:     "head",
+		HelpName: "head",
+		Usage:    "print remote object metadata",
+
+		CustomHelpTemplate: headHelpTemplate,
+
+		Flags: []cli.Flag{
+			&cli.StringFlag{
+				Name:  "version-id",
+				Usage: "use the specified version of an object",
+			},
+			&cli.BoolFlag{
+				Name:  "raw",
+				Usage: "disable the wildcard operations, useful with filenames that contains glob characters",
+			},
+		},
+
+		Before: func(c *cli.Context) error {
+			err := validateHeadCommand(c)
+			if err != nil {
+				printError(commandFromContext(c), c.Command.Name, err)
+			}
+			return err
+		},
+		Action: func(c *cli.Context) (err error) {
+			defer stat.Collect(c.Command.FullName(), &err)()
+
+			op := c.Command.Name
+			fullCommand := commandFromContext(c)
+			src, err := url.New(c.Args().Get(0), url.WithVersion(c.String("version-id")),
+				url.WithRaw(c.Bool("raw")))
+			if err != nil {
+				printError(fullCommand, op, err)
+				return err
+			}
+
+			return Head{
+				src:         src,
+				op:          op,
+				fullCommand: fullCommand,
+				storageOpts: NewStorageOpts(c),
+			}.Run(c.Context)
+		},
+	}
+	cmd.BashComplete = getBashCompleteFn(cmd, true, false)
+	return cmd
+}
+
+type Head struct {
+	src         *url.URL
+	op          string
+	fullCommand string
+	storageOpts storage.Options
+}
+
+func (h Head) Run(ctx context.Context) error {
+	client, err := storage.NewRemoteClient(ctx, h.src, h.storageOpts)
+	if err != nil {
+		printError(h.fullCommand, h.op, err)
+		return err
+	}
+
+	if h.src.IsBucket() {
+		err := client.HeadBucket(ctx, h.src)
+		if err != nil {
+			printError(h.fullCommand, h.op, err)
+			return err
+		}
+
+		msg := HeadBucketMessage{
+			Bucket: h.src.String(),
+		}
+
+		log.Info(msg)
+
+		return nil
+	}
+
+	object, metadata, err := client.HeadObject(ctx, h.src)
+	if err != nil {
+		printError(h.fullCommand, h.op, err)
+		return err
+	}
+
+	msg := HeadObjectMessage{
+		Key:                  object.URL.String(),
+		ContentType:          metadata.ContentType,
+		ServerSideEncryption: metadata.EncryptionMethod,
+		LastModified:         object.ModTime,
+		ContentLength:        object.Size,
+		StorageClass:         string(object.StorageClass),
+		VersionID:            object.VersionID,
+		ETag:                 object.Etag,
+		Metadata:             metadata.UserDefined,
+	}
+
+	log.Info(msg)
+
+	return nil
+}
+
+type HeadObjectMessage struct {
+	Key                  string            `json:"key,omitempty"`
+	ContentType          string            `json:"content_type,omitempty"`
+	ServerSideEncryption string            `json:"server_side_encryption,omitempty"`
+	LastModified         *time.Time        `json:"last_modified,omitempty"`
+	ContentLength        int64             `json:"size,omitempty"`
+	StorageClass         string            `json:"storage_class,omitempty"`
+	VersionID            string            `json:"version_id,omitempty"`
+	ETag                 string            `json:"etag,omitempty"`
+	Metadata             map[string]string `json:"metadata"`
+}
+
+func (m HeadObjectMessage) String() string {
+	return m.JSON()
+}
+
+func (m HeadObjectMessage) JSON() string {
+	return strutil.JSON(m)
+}
+
+type HeadBucketMessage struct {
+	Bucket string `json:"bucket"`
+}
+
+func (m HeadBucketMessage) String() string {
+	return m.JSON()
+}
+
+func (m HeadBucketMessage) JSON() string {
+	return strutil.JSON(m)
+}
+
+func validateHeadCommand(c *cli.Context) error {
+	if c.Args().Len() > 1 {
+		return fmt.Errorf("object or bucket name is required")
+	}
+
+	srcurl, err := url.New(c.Args().Get(0), url.WithVersion(c.String("version-id")),
+		url.WithRaw(c.Bool("raw")))
+	if err != nil {
+		return err
+	}
+
+	if srcurl.IsPrefix() {
+		return fmt.Errorf("target have to be a object or a bucket")
+	}
+
+	if !srcurl.IsRemote() {
+		return fmt.Errorf("target should be remote object or bucket")
+	}
+
+	if srcurl.IsWildcard() && !srcurl.IsRaw() {
+		return fmt.Errorf("remote source %q can not contain glob characters", srcurl)
+	}
+
+	if err := checkVersinoningURLRemote(srcurl); err != nil {
+		return err
+	}
+
+	if err := checkVersioningWithGoogleEndpoint(c); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/command/ls.go b/command/ls.go
index e27e54c..085b8ae 100644
--- a/command/ls.go
+++ b/command/ls.go
@@ -254,7 +254,7 @@ func (l ListMessage) String() string {
 		return l.Object.URL.String()
 	}
 	var etag string
-	// date and storage fiels
+	// date and storage fields
 	var listFormat = "%19s %2s"
 
 	// align etag
diff --git a/storage/s3.go b/storage/s3.go
index 34213d9..c49de30 100644
--- a/storage/s3.go
+++ b/storage/s3.go
@@ -282,7 +282,6 @@ func (s *S3) listObjectVersions(ctx context.Context, url *url.URL) <-chan *Objec
 
 				return !lastPage
 			})
-
 		if err != nil {
 			objCh <- &Object{Err: err}
 			return
@@ -372,7 +371,6 @@ func (s *S3) listObjectsV2(ctx context.Context, url *url.URL) <-chan *Object {
 
 			return !lastPage
 		})
-
 		if err != nil {
 			objCh <- &Object{Err: err}
 			return
@@ -464,7 +462,6 @@ func (s *S3) listObjects(ctx context.Context, url *url.URL) <-chan *Object {
 
 			return !lastPage
 		})
-
 		if err != nil {
 			objCh <- &Object{Err: err}
 			return
@@ -579,7 +576,6 @@ func (s *S3) Read(ctx context.Context, src *url.URL) (io.ReadCloser, error) {
 	}
 
 	resp, err := s.api.GetObjectWithContext(ctx, input)
-
 	if err != nil {
 		return nil, err
 	}
@@ -720,7 +716,6 @@ func (s *S3) Select(ctx context.Context, url *url.URL, query *SelectQuery, resul
 		query.InputContentStructure,
 		query.FileHeaderInfo,
 	)
-
 	if err != nil {
 		return err
 	}
@@ -736,7 +731,6 @@ func (s *S3) Select(ctx context.Context, url *url.URL, query *SelectQuery, resul
 		query.InputContentStructure,
 		reader,
 	)
-
 	if err != nil {
 		return err
 	}
@@ -889,8 +883,8 @@ func (s *S3) Put(
 }
 
 func (s *S3) retryOnNoSuchUpload(ctx aws.Context, to *url.URL, input *s3manager.UploadInput,
-	err error, uploaderOpts ...func(*s3manager.Uploader)) error {
-
+	err error, uploaderOpts ...func(*s3manager.Uploader),
+) error {
 	var expectedRetryID string
 	if ID, ok := input.Metadata[metadataKeyRetryID]; ok {
 		expectedRetryID = *ID
@@ -1161,7 +1155,56 @@ func (s *S3) GetBucketVersioning(ctx context.Context, bucket string) (string, er
 	}
 
 	return *output.Status, nil
+}
+
+func (s *S3) HeadBucket(ctx context.Context, url *url.URL) error {
+	_, err := s.api.HeadBucketWithContext(ctx, &s3.HeadBucketInput{
+		Bucket: aws.String(url.Bucket),
+	})
+	return err
+}
+
+func (s *S3) HeadObject(ctx context.Context, url *url.URL) (*Object, *Metadata, error) {
+	input := &s3.HeadObjectInput{
+		Bucket:       aws.String(url.Bucket),
+		Key:          aws.String(url.Path),
+		RequestPayer: s.RequestPayer(),
+	}
+
+	if url.VersionID != "" {
+		input.SetVersionId(url.VersionID)
+	}
 
+	output, err := s.api.HeadObjectWithContext(ctx, input)
+	if err != nil {
+		if errHasCode(err, "NotFound") {
+			return nil, nil, &ErrGivenObjectNotFound{ObjectAbsPath: url.Absolute()}
+		}
+		return nil, nil, err
+	}
+
+	// https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html#AmazonS3-HeadObject-response-header-StorageClass
+	// If the object's storage class is STANDARD, this header is not returned in the response.
+	storageClassStr := "STANDARD"
+	if output.StorageClass != nil {
+		storageClassStr = aws.StringValue(output.StorageClass)
+	}
+
+	obj := &Object{
+		URL:          url,
+		ModTime:      output.LastModified,
+		Etag:         strings.Trim(aws.StringValue(output.ETag), `"`),
+		Size:         aws.Int64Value(output.ContentLength),
+		StorageClass: StorageClass(storageClassStr),
+	}
+
+	metadata := &Metadata{
+		ContentType:      aws.StringValue(output.ContentType),
+		EncryptionMethod: aws.StringValue(output.ServerSideEncryption),
+		UserDefined:      aws.StringValueMap(output.Metadata),
+	}
+
+	return obj, metadata, nil
 }
 
 type sdkLogger struct{}
@@ -1400,7 +1443,6 @@ func errHasCode(err error, code string) bool {
 	}
 
 	return false
-
 }
 
 // IsCancelationError reports whether given error is a storage related
diff --git a/storage/storage.go b/storage/storage.go
index eb59e17..1a61712 100644
--- a/storage/storage.go
+++ b/storage/storage.go
@@ -18,11 +18,8 @@ import (
 	"github.com/peak/s5cmd/v2/strutil"
 )
 
-var (
-
-	// ErrNoObjectFound indicates there are no objects found from a given directory.
-	ErrNoObjectFound = fmt.Errorf("no object found")
-)
+// ErrNoObjectFound indicates there are no objects found from a given directory.
+var ErrNoObjectFound = fmt.Errorf("no object found")
 
 // ErrGivenObjectNotFound indicates a specified object is not found.
 type ErrGivenObjectNotFound struct {
diff --git a/storage/url/url.go b/storage/url/url.go
index 63ef4a8..a9489b7 100644
--- a/storage/url/url.go
+++ b/storage/url/url.go
@@ -399,6 +399,10 @@ func (u *URL) IsWildcard() bool {
 	return !u.raw && hasGlobCharacter(u.Path)
 }
 
+func (u *URL) IsRaw() bool {
+	return u.raw
+}
+
 // parseBatch parses keys for wildcard operations.
 // It cuts the key starting from first directory before the
 // wildcard part (filter)


Test patch:
diff --git a/e2e/head_test.go b/e2e/head_test.go
new file mode 100644
index 0000000..699e9b3
--- /dev/null
+++ b/e2e/head_test.go
@@ -0,0 +1,332 @@
+package e2e
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"gotest.tools/v3/assert"
+	"gotest.tools/v3/icmd"
+)
+
+// head (without anything -> error)
+func TestHead(t *testing.T) {
+	t.Parallel()
+
+	_, s5cmd := setup(t)
+
+	cmd := s5cmd("head")
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: match(`ERROR "head": target should be remote object or bucket`),
+	})
+}
+
+// head bucket
+func TestHeadBucket(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	cmd := s5cmd("head", fmt.Sprintf("s3://%v", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: suffix(`{"bucket":"s3://%v"}`, bucket),
+	}, jsonCheck(true), strictLineCheck(false))
+}
+
+// head bucket (non-existent)
+func TestHeadBucketNonExistent(t *testing.T) {
+	t.Parallel()
+
+	_, s5cmd := setup(t)
+
+	cmd := s5cmd("head", "s3://non-existent-bucket")
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: contains(`ERROR "head s3://non-existent-bucket": NotFound: Not Found status code: 404`),
+	})
+}
+
+// --json head bucket
+func TestHeadBucketJSON(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	cmd := s5cmd("--json", "head", fmt.Sprintf("s3://%v", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: suffix(`{"bucket":"s3://%v"}`, bucket),
+	}, jsonCheck(true), strictLineCheck(false))
+}
+
+// --json head bucket (non-existent)
+func TestHeadBucketJSONNonExistent(t *testing.T) {
+	t.Parallel()
+
+	_, s5cmd := setup(t)
+
+	cmd := s5cmd("--json", "head", "s3://non-existent-bucket")
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+	assert.Equal(t, strings.Contains(result.Stderr(), `"error":"NotFound: Not Found status code: 404`), true)
+}
+
+// head object
+func TestHeadObject(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+	putFile(t, s3client, bucket, "myfile.txt", "content")
+
+	cmd := s5cmd("head", fmt.Sprintf("s3://%v/myfile.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	expectedOutput := fmt.Sprintf(`{"key":"s3://%v/myfile.txt","last_modified":"[0-9-]+T[0-9:.]+Z","size":\d+,"storage_class":"STANDARD","etag":"[a-f0-9]+","metadata":\{\}}`, bucket)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: match(expectedOutput),
+	}, jsonCheck(true), strictLineCheck(false))
+}
+
+// head object (non-existent)
+func TestHeadObjectNonExistent(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	cmd := s5cmd("head", fmt.Sprintf("s3://%v/non-existent-file.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: contains(`non-existent-file.txt not found`),
+	})
+}
+
+// --json head object
+func TestHeadObjectJSON(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+	putFile(t, s3client, bucket, "file.txt", "content")
+
+	cmd := s5cmd("--json", "head", fmt.Sprintf("s3://%v/file.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	expectedOutput := fmt.Sprintf(`{"key":"s3://%v/file.txt","last_modified":"[0-9-]+T[0-9:.]+Z","size":\d+,"storage_class":"STANDARD","etag":"[a-f0-9]+","metadata":\{\}}`, bucket)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: match(expectedOutput),
+	}, jsonCheck(true), strictLineCheck(false))
+}
+
+// --json head object (non-existent)
+func TestHeadObjectJSONNonExistent(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	cmd := s5cmd("--json", "head", fmt.Sprintf("s3://%v/non-existent-file.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: contains(`non-existent-file.txt not found`),
+	}, jsonCheck(true), strictLineCheck(false))
+}
+
+// head --raw objectWithGlobChar
+// head --raw s3://bucket/file*.txt
+func TestHeadObjectRaw(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+	putFile(t, s3client, bucket, "file*.txt", "content")
+
+	cmd := s5cmd("head", "--raw", fmt.Sprintf("s3://%v/file*.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	expectedOutput := fmt.Sprintf(`{"key":"s3://%v/file\*.txt","last_modified":"[0-9-]+T[0-9:.]+Z","size":\d+,"storage_class":"STANDARD","etag":"[a-f0-9]+","metadata":\{\}}`, bucket)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: match(expectedOutput),
+	}, jsonCheck(true), strictLineCheck(false))
+}
+
+// head object s3://bucket/file*.txt
+func TestHeadObjectWildcardWithoutRawFlag(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	putFile(t, s3client, bucket, "file.txt", "content")
+
+	cmd := s5cmd("head", fmt.Sprintf("s3://%v/file*.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: contains(`can not contain glob characters`),
+	})
+}
+
+// head --version-id object s3://bucket/file.txt
+func TestHeadObjectWithVersionID(t *testing.T) {
+	skipTestIfGCS(t, "versioning is not supported in GCS")
+
+	t.Parallel()
+
+	bucket := s3BucketFromTestName(t)
+
+	// versioninng is only supported with in memory backend!
+	s3client, s5cmd := setup(t, withS3Backend("mem"))
+
+	const filename = "testfile.txt"
+
+	contents := []string{
+		"This is first content",
+		"Second content it is, and it is a bit longer!!!",
+	}
+
+	// create a bucket and Enable versioning
+	createBucket(t, s3client, bucket)
+	setBucketVersioning(t, s3client, bucket, "Enabled")
+
+	// upload two versions of the file with same key
+	putFile(t, s3client, bucket, filename, contents[0])
+	putFile(t, s3client, bucket, filename, contents[1])
+
+	// now we will list and parse their version IDs
+	cmd := s5cmd("ls", "--all-versions", "s3://"+bucket+"/"+filename)
+	result := icmd.RunCmd(cmd)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: contains("%v", filename),
+		1: contains("%v", filename),
+	})
+
+	versionIDs := make([]string, 0)
+	for _, row := range strings.Split(result.Stdout(), "\n") {
+		if row != "" {
+			arr := strings.Split(row, " ")
+			versionIDs = append(versionIDs, arr[len(arr)-1])
+		}
+	}
+
+	for i, version := range versionIDs {
+		cmd = s5cmd("head", "--version-id", version,
+			fmt.Sprintf("s3://%v/%v", bucket, filename))
+
+		result = icmd.RunCmd(cmd)
+		result.Assert(t, icmd.Success)
+
+		expectedOutput := fmt.Sprintf(`{"key":"s3://%v/testfile.txt","last_modified":"[0-9-]+T[0-9:.]+Z","size":%d,"storage_class":"STANDARD","etag":"[a-f0-9]+","metadata":\{\}}`, bucket, len(contents[i]))
+
+		assertLines(t, result.Stdout(), map[int]compareFunc{
+			0: match(expectedOutput),
+		}, jsonCheck(true), strictLineCheck(false))
+	}
+}
+
+// head s3://bucket/file.txt (file.txt has metadata)
+func TestHeadObjectWithMetadata(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	metadata := make(map[string]*string)
+	value := "value1"
+	value2 := "value2"
+	metadata["key1"] = &value
+	metadata["key2"] = &value2
+
+	putFile(t, s3client, bucket, "file.txt", "content", putArbitraryMetadata(metadata))
+
+	cmd := s5cmd("head", fmt.Sprintf("s3://%v/file.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	expectedOutput := fmt.Sprintf(`{"key":"s3://%v/file.txt","last_modified":"[0-9-]+T[0-9:.]+Z","size":\d+,"storage_class":"STANDARD","etag":"[a-f0-9]+","metadata":{(?:"key1":"value1","key2":"value2"|"key2":"value2","key1":"value1")}}`, bucket)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: match(expectedOutput),
+	}, jsonCheck(true), strictLineCheck(false))
+}
+
+// head --json object s3://bucket/file.txt (metadata)
+func TestHeadObjectJSONMetadata(t *testing.T) {
+	t.Parallel()
+
+	s3client, s5cmd := setup(t)
+
+	bucket := s3BucketFromTestName(t)
+	createBucket(t, s3client, bucket)
+
+	metadata := make(map[string]*string)
+	value := "value1"
+
+	metadata["key1"] = &value
+
+	putFile(t, s3client, bucket, "file.txt", "content", putArbitraryMetadata(metadata))
+
+	cmd := s5cmd("--json", "head", fmt.Sprintf("s3://%v/file.txt", bucket))
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	expectedOutput := fmt.Sprintf(`{"key":"s3://%v/file.txt","last_modified":"[0-9-]+T[0-9:.]+Z","size":\d+,"storage_class":"STANDARD","etag":"[a-f0-9]+","metadata":{"key1":"value1"}}`, bucket)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: match(expectedOutput),
+	}, jsonCheck(true), strictLineCheck(false))
+}
diff --git a/storage/s3_test.go b/storage/s3_test.go
index aee355b..11b52eb 100644
--- a/storage/s3_test.go
+++ b/storage/s3_test.go
@@ -81,7 +81,6 @@ func TestNewSessionPathStyle(t *testing.T) {
 	for _, tc := range testcases {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
-
 			opts := Options{Endpoint: tc.endpoint.String()}
 			sess, err := globalSessionCache.newSession(context.Background(), opts)
 			if err != nil {
@@ -180,7 +179,6 @@ aws_secret_access_key = p2_profile_access_key`
 			expSecretAccessKey: "p1_profile_access_key",
 		},
 		{
-
 			name:               "use a non-existent profile",
 			fileName:           file.Name(),
 			profileName:        "non-existent-profile",
@@ -698,7 +696,6 @@ func TestS3CopyEncryptionRequest(t *testing.T) {
 	for _, tc := range testcases {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
-
 			mockAPI := s3.New(unit.Session)
 
 			mockAPI.Handlers.Unmarshal.Clear()
@@ -707,7 +704,6 @@ func TestS3CopyEncryptionRequest(t *testing.T) {
 			mockAPI.Handlers.Send.Clear()
 
 			mockAPI.Handlers.Send.PushBack(func(r *request.Request) {
-
 				r.HTTPResponse = &http.Response{
 					StatusCode: http.StatusOK,
 					Body:       io.NopCloser(strings.NewReader("")),
@@ -751,7 +747,6 @@ func TestS3CopyEncryptionRequest(t *testing.T) {
 			metadata.ACL = tc.acl
 
 			err = mockS3.Copy(context.Background(), u, u, metadata)
-
 			if err != nil {
 				t.Errorf("Expected %v, but received %q", nil, err)
 			}
@@ -803,7 +798,6 @@ func TestS3PutEncryptionRequest(t *testing.T) {
 	for _, tc := range testcases {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
-
 			mockAPI := s3.New(unit.Session)
 
 			mockAPI.Handlers.Unmarshal.Clear()
@@ -812,7 +806,6 @@ func TestS3PutEncryptionRequest(t *testing.T) {
 			mockAPI.Handlers.Send.Clear()
 
 			mockAPI.Handlers.Send.PushBack(func(r *request.Request) {
-
 				r.HTTPResponse = &http.Response{
 					StatusCode: http.StatusOK,
 					Body:       io.NopCloser(strings.NewReader("")),
@@ -847,7 +840,6 @@ func TestS3PutEncryptionRequest(t *testing.T) {
 			metadata.ACL = tc.acl
 
 			err = mockS3.Put(context.Background(), bytes.NewReader([]byte("")), u, metadata, 1, 5242880)
-
 			if err != nil {
 				t.Errorf("Expected %v, but received %q", nil, err)
 			}
@@ -1257,6 +1249,60 @@ func TestAWSLogLevel(t *testing.T) {
 	}
 }
 
+func TestS3HeadObject(t *testing.T) {
+	testcases := []struct {
+		name     string
+		url      string
+		expected string
+	}{
+		{
+			name:     "HeadObject",
+			url:      "s3://bucket/key",
+			expected: "bucket/key",
+		},
+		{
+			name:     "HeadObject with different URL",
+			url:      "s3://another-bucket/another-key",
+			expected: "another-bucket/another-key",
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			u, err := url.New(tc.url)
+			if err != nil {
+				t.Errorf("unexpected error: %v", err)
+			}
+
+			mockAPI := s3.New(unit.Session)
+			mockS3 := &S3{api: mockAPI}
+
+			mockAPI.Handlers.Send.Clear()
+			mockAPI.Handlers.Unmarshal.Clear()
+			mockAPI.Handlers.UnmarshalMeta.Clear()
+			mockAPI.Handlers.ValidateResponse.Clear()
+
+			mockAPI.Handlers.Send.PushBack(func(r *request.Request) {
+				r.HTTPResponse = &http.Response{
+					StatusCode: http.StatusOK,
+					Body:       io.NopCloser(strings.NewReader("")),
+				}
+			})
+
+			mockAPI.Handlers.ValidateResponse.PushBack(func(r *request.Request) {
+				if r.Error != nil {
+					t.Errorf("unexpected error: %v", r.Error)
+				}
+			})
+
+			_, _, err = mockS3.HeadObject(context.Background(), u)
+			if err != nil {
+				t.Errorf("unexpected error: %v", err)
+			}
+		})
+	}
+}
+
 func valueAtPath(i interface{}, s string) interface{} {
 	v, err := awsutil.ValuesAtPath(i, s)
 	if err != nil || len(v) == 0 {