Repository: minio/mc

Issue:
`mc admin replicate resync` corrupts the terminal if invalid args are provided
## Expected behavior

Running `mc admin replicate resync status` with no additonal args or with only one alias should print an error message or show the help as per other commands.

The same goes for `mc admin replicate resync start/cancel`.

## Actual behavior

Running any of the `mc admin replicate` subcommands with an incorrect number of commands produces no output and leaves the terminal in a bad state. The `reset` command is needed to restore the terminal's state.

## Steps to reproduce the behavior

Running any of the following with no addional args will trigger the problem:

```
mc admin replicate resync start
mc admin replicate resync status
mc admin replicate resync cancel
```

The command line prompt will likely be messed up and the cursor will be invisible.

## mc --version

```
mc version RELEASE.2025-03-12T17-29-24Z (commit-id=c1d5d4cbb4caf05afef3ea06a91a56bd778336de)
Runtime: go1.24.1 linux/amd64
Copyright (c) 2015-2025 MinIO, Inc.
License GNU AGPLv3 <https://www.gnu.org/licenses/agpl-3.0.html>
```

## System information

- RHEL 8.6 
- amd64

Hints from issue comments:

Solution patch:
diff --git a/cmd/admin-accesskey-sts-revoke.go b/cmd/admin-accesskey-sts-revoke.go
new file mode 100644
index 00000000..bb4e72f8
--- /dev/null
+++ b/cmd/admin-accesskey-sts-revoke.go
@@ -0,0 +1,149 @@
+// Copyright (c) 2015-2025 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package cmd
+
+import (
+	"github.com/minio/cli"
+	json "github.com/minio/colorjson"
+	"github.com/minio/madmin-go/v3"
+	"github.com/minio/mc/pkg/probe"
+)
+
+var adminAccesskeySTSRevokeFlags = []cli.Flag{
+	cli.BoolFlag{
+		Name:  "all",
+		Usage: "revoke all STS accounts for the specified user",
+	},
+	cli.BoolFlag{
+		Name:  "self",
+		Usage: "revoke all STS accounts for the authenticated user",
+	},
+	cli.StringFlag{
+		Name:  "token-type",
+		Usage: "specify the token type to revoke",
+	},
+}
+
+var adminAccesskeySTSRevokeCmd = cli.Command{
+	Name:         "sts-revoke",
+	Usage:        "revokes all STS accounts or specified types for the specified user",
+	Action:       mainAdminAccesskeySTSRevoke,
+	OnUsageError: onUsageError,
+	Before:       setGlobalsFromContext,
+	Flags:        append(adminAccesskeySTSRevokeFlags, globalFlags...),
+	CustomHelpTemplate: `NAME:
+  {{.HelpName}} - {{.Usage}}
+
+USAGE:
+  {{.HelpName}} ALIAS USER [--all | --token-type TOKEN_TYPE]
+
+  Exactly one of --all or --token-type must be specified.
+
+FLAGS:
+  {{range .VisibleFlags}}{{.}}
+  {{end}}
+EXAMPLES:
+  1. Revoke all STS accounts for user "user1"
+	 {{.Prompt}} {{.HelpName}} myminio user1 --all
+
+  2. Revoke STS accounts of a token type "app-1" for user "user1"
+	 {{.Prompt}} {{.HelpName}} myminio user1 --token-type app-1
+
+  3. Revoke all STS accounts for the authenticated user
+	 {{.Prompt}} {{.HelpName}} myminio --self
+
+  4. Revoke STS accounts of a token type "app-1" for the authenticated user
+	 {{.Prompt}} {{.HelpName}} myminio --self --token-type app-1
+`,
+}
+
+type stsRevokeMessage struct {
+	Status          string `json:"status"`
+	User            string `json:"user"`
+	TokenRevokeType string `json:"tokenRevokeType,omitempty"`
+}
+
+func (m stsRevokeMessage) String() string {
+	userString := "user " + m.User
+	if m.User == "" {
+		userString = "authenticated user"
+	}
+	if m.TokenRevokeType == "" {
+		return "Successfully revoked all STS accounts for " + userString
+	}
+	return "Successfully revoked all STS accounts of type " + m.TokenRevokeType + " for " + userString
+}
+
+func (m stsRevokeMessage) JSON() string {
+	if m.Status == "" {
+		m.Status = "success"
+	}
+	jsonMessageBytes, e := json.MarshalIndent(m, "", " ")
+	fatalIf(probe.NewError(e), "Unable to marshal into JSON.")
+
+	return string(jsonMessageBytes)
+}
+
+// checkSTSRevokeSyntax - validate all the passed arguments
+func checkSTSRevokeSyntax(ctx *cli.Context) {
+	if len(ctx.Args()) > 2 || len(ctx.Args()) == 0 {
+		showCommandHelpAndExit(ctx, 1)
+	}
+
+	if !ctx.Bool("self") && ctx.Args().Get(1) == "" {
+		fatalIf(errInvalidArgument().Trace(), "Must specify user or use --self flag.")
+	}
+
+	if ctx.Bool("self") && ctx.Args().Get(1) != "" {
+		fatalIf(errInvalidArgument().Trace(), "Cannot specify user with --self flag.")
+	}
+
+	if (!ctx.Bool("all") && ctx.String("token-type") == "") || (ctx.Bool("all") && ctx.String("token-type") != "") {
+		fatalIf(errDummy().Trace(), "Exactly one of --all or --token-type must be specified.")
+	}
+}
+
+// mainAdminAccesskeySTSRevoke is the handle for "mc admin accesskey sts-revoke" command.
+func mainAdminAccesskeySTSRevoke(ctx *cli.Context) error {
+	checkSTSRevokeSyntax(ctx)
+
+	// Get the alias parameter from cli
+	args := ctx.Args()
+	aliasedURL := args.Get(0)
+	user := args.Get(1) // will be empty if --self flag is set
+	tokenRevokeType := ctx.String("token-type")
+	fullRevoke := ctx.Bool("all")
+
+	// Create a new MinIO Admin Client
+	client, err := newAdminClient(aliasedURL)
+	fatalIf(err, "Unable to initialize admin connection.")
+
+	e := client.RevokeTokens(globalContext, madmin.RevokeTokensReq{
+		User:            user,
+		TokenRevokeType: tokenRevokeType,
+		FullRevoke:      fullRevoke,
+	})
+	fatalIf(probe.NewError(e).Trace(args...), "Unable to revoke tokens for %s", user)
+
+	printMsg(stsRevokeMessage{
+		User:            user,
+		TokenRevokeType: tokenRevokeType,
+	})
+
+	return nil
+}
diff --git a/cmd/admin-accesskey.go b/cmd/admin-accesskey.go
index b5f0a0e1..9db279d1 100644
--- a/cmd/admin-accesskey.go
+++ b/cmd/admin-accesskey.go
@@ -27,6 +27,7 @@ var adminAccesskeySubcommands = []cli.Command{
 	adminAccesskeyEditCmd,
 	adminAccesskeyEnableCmd,
 	adminAccesskeyDisableCmd,
+	adminAccesskeySTSRevokeCmd,
 }
 
 var adminAccesskeyCmd = cli.Command{
diff --git a/cmd/admin-replicate-resync-cancel.go b/cmd/admin-replicate-resync-cancel.go
index 46679692..852ebedb 100644
--- a/cmd/admin-replicate-resync-cancel.go
+++ b/cmd/admin-replicate-resync-cancel.go
@@ -74,13 +74,12 @@ func (m resyncCancelMessage) String() string {
 }
 
 func mainAdminReplicateResyncCancel(ctx *cli.Context) error {
-	{
-		// Check argument count
-		argsNr := len(ctx.Args())
-		if argsNr != 2 {
-			cli.ShowCommandHelpAndExit(ctx, "cancel", 1) // last argument is exit code
-		}
+	// Check argument count
+	argsNr := len(ctx.Args())
+	if argsNr != 2 {
+		showCommandHelpAndExit(ctx, 1) // last argument is exit code
 	}
+
 	console.SetColor("ResyncMessage", color.New(color.FgGreen))
 
 	// Get the alias parameter from cli
diff --git a/cmd/admin-replicate-resync-start.go b/cmd/admin-replicate-resync-start.go
index 66c26877..f3a31e93 100644
--- a/cmd/admin-replicate-resync-start.go
+++ b/cmd/admin-replicate-resync-start.go
@@ -21,10 +21,11 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/minio/madmin-go/v3"
+
 	"github.com/fatih/color"
 	"github.com/minio/cli"
 	json "github.com/minio/colorjson"
-	"github.com/minio/madmin-go/v3"
 	"github.com/minio/mc/pkg/probe"
 	"github.com/minio/pkg/v3/console"
 )
@@ -74,12 +75,10 @@ func (m resyncMessage) String() string {
 }
 
 func mainAdminReplicateResyncStart(ctx *cli.Context) error {
-	{
-		// Check argument count
-		argsNr := len(ctx.Args())
-		if argsNr != 2 {
-			cli.ShowCommandHelpAndExit(ctx, "start", 1) // last argument is exit code
-		}
+	// Check argument count
+	argsNr := len(ctx.Args())
+	if argsNr != 2 {
+		showCommandHelpAndExit(ctx, 1) // last argument is exit code
 	}
 
 	console.SetColor("ResyncMessage", color.New(color.FgGreen))
diff --git a/cmd/admin-replicate-resync-status.go b/cmd/admin-replicate-resync-status.go
index 4b137293..21a8b860 100644
--- a/cmd/admin-replicate-resync-status.go
+++ b/cmd/admin-replicate-resync-status.go
@@ -60,12 +60,10 @@ EXAMPLES:
 }
 
 func mainAdminReplicationResyncStatus(ctx *cli.Context) error {
-	{
-		// Check argument count
-		argsNr := len(ctx.Args())
-		if argsNr != 2 {
-			cli.ShowCommandHelpAndExit(ctx, "status", 1) // last argument is exit code
-		}
+	// Check argument count
+	argsNr := len(ctx.Args())
+	if argsNr != 2 {
+		showCommandHelpAndExit(ctx, 1) // last argument is exit code
 	}
 
 	console.SetColor("ResyncMessage", color.New(color.FgGreen))
diff --git a/cmd/auto-complete.go b/cmd/auto-complete.go
index f6b56215..09e8b12a 100644
--- a/cmd/auto-complete.go
+++ b/cmd/auto-complete.go
@@ -391,16 +391,18 @@ var completeCmds = map[string]complete.Predictor{
 	"/idp/ldap/accesskey/edit":              aliasCompleter,
 	"/idp/ldap/accesskey/enable":            aliasCompleter,
 	"/idp/ldap/accesskey/disable":           aliasCompleter,
-
-	"/admin/accesskey/create":  aliasCompleter,
-	"/admin/accesskey/list":    aliasCompleter,
-	"/admin/accesskey/ls":      aliasCompleter,
-	"/admin/accesskey/remove":  aliasCompleter,
-	"/admin/accesskey/rm":      aliasCompleter,
-	"/admin/accesskey/info":    aliasCompleter,
-	"/admin/accesskey/edit":    aliasCompleter,
-	"/admin/accesskey/enable":  aliasCompleter,
-	"/admin/accesskey/disable": aliasCompleter,
+	"/idp/ldap/accesskey/sts-revoke":        aliasCompleter,
+
+	"/admin/accesskey/create":     aliasCompleter,
+	"/admin/accesskey/list":       aliasCompleter,
+	"/admin/accesskey/ls":         aliasCompleter,
+	"/admin/accesskey/remove":     aliasCompleter,
+	"/admin/accesskey/rm":         aliasCompleter,
+	"/admin/accesskey/info":       aliasCompleter,
+	"/admin/accesskey/edit":       aliasCompleter,
+	"/admin/accesskey/enable":     aliasCompleter,
+	"/admin/accesskey/disable":    aliasCompleter,
+	"/admin/accesskey/sts-revoke": aliasCompleter,
 
 	"/admin/policy/info":     aliasCompleter,
 	"/admin/policy/update":   aliasCompleter,
diff --git a/cmd/client-url.go b/cmd/client-url.go
index 561773bb..5a56851b 100644
--- a/cmd/client-url.go
+++ b/cmd/client-url.go
@@ -267,3 +267,37 @@ func guessURLContentType(urlStr string) string {
 	contentType := mimedb.TypeByExtension(filepath.Ext(url.Path))
 	return contentType
 }
+
+// urlParts - split URL into parts.
+func urlParts(urlStr string) []string {
+	// Convert '/' on windows to filepath.Separator.
+	urlStr = filepath.FromSlash(urlStr)
+
+	if runtime.GOOS == "windows" {
+		// Remove '/' prefix before alias if any to support '\\home' alias
+		// style under Windows
+		urlStr = strings.TrimPrefix(urlStr, string(filepath.Separator))
+	}
+
+	// Remove everything after alias (i.e. after '/').
+	return strings.Split(urlStr, string(filepath.Separator))
+}
+
+// isURLPrefix - check if source and destination be subdirectories of each other
+func isURLPrefix(src string, dest string) bool {
+	srcURLParts := urlParts(src)
+	dstURLParts := urlParts(dest)
+	minIndex := min(len(srcURLParts), len(dstURLParts))
+	isPrefix := true
+	for i := 0; i < minIndex; i++ {
+		// if one of the URLs ends with '/' and other does not
+		if (i == minIndex-1) && (dstURLParts[i] == "" || srcURLParts[i] == "" || dstURLParts[i] == "*" || srcURLParts[i] == "*") {
+			continue
+		}
+		if srcURLParts[i] != dstURLParts[i] {
+			isPrefix = false
+			break
+		}
+	}
+	return isPrefix
+}
diff --git a/cmd/idp-ldap-accesskey-sts-revoke.go b/cmd/idp-ldap-accesskey-sts-revoke.go
new file mode 100644
index 00000000..7fccf763
--- /dev/null
+++ b/cmd/idp-ldap-accesskey-sts-revoke.go
@@ -0,0 +1,90 @@
+// Copyright (c) 2015-2025 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package cmd
+
+import (
+	"github.com/minio/cli"
+	"github.com/minio/madmin-go/v3"
+	"github.com/minio/mc/pkg/probe"
+)
+
+var idpLdapAccesskeySTSRevokeCmd = cli.Command{
+	Name:         "sts-revoke",
+	Usage:        "revokes all STS accounts or specified types for the specified user",
+	Action:       mainIdpLdapAccesskeySTSRevoke,
+	OnUsageError: onUsageError,
+	Before:       setGlobalsFromContext,
+	Flags:        append(adminAccesskeySTSRevokeFlags, globalFlags...),
+	CustomHelpTemplate: `NAME:
+  {{.HelpName}} - {{.Usage}}
+
+USAGE:
+  {{.HelpName}} ALIAS USER [--all | --token-type TOKEN_TYPE]
+
+  Exactly one of --all or --token-type must be specified.
+
+FLAGS:
+  {{range .VisibleFlags}}{{.}}
+  {{end}}
+EXAMPLES:
+  1. Revoke all STS accounts for LDAP user 'bobfisher'
+	 {{.Prompt}} {{.HelpName}} myminio uid=bobfisher,ou=people,ou=hwengg,dc=min,dc=io --all
+
+  2. Revoke all STS accounts for LDAP user 'bobfisher' (alt)
+	 {{.Prompt}} {{.HelpName}} myminio bobfisher --all
+
+  3. Revoke STS accounts of a token type 'app-1' for user 'user1'
+	 {{.Prompt}} {{.HelpName}} myminio user1 --token-type app-1
+
+  4. Revoke all STS accounts for the authenticated user (must be LDAP service account)
+	 {{.Prompt}} {{.HelpName}} myminio --self
+
+  5. Revoke STS accounts of a token type 'app-1' for the authenticated user (must be LDAP service account)
+	 {{.Prompt}} {{.HelpName}} myminio --self --token-type app-1
+`,
+}
+
+// mainIdpLdapAccesskeySTSRevoke is the handle for "mc idp ldap accesskey sts-revoke" command.
+func mainIdpLdapAccesskeySTSRevoke(ctx *cli.Context) error {
+	checkSTSRevokeSyntax(ctx)
+
+	// Get the alias parameter from cli
+	args := ctx.Args()
+	aliasedURL := args.Get(0)
+	user := args.Get(1) // will be empty if --self flag is set
+	tokenRevokeType := ctx.String("token-type")
+	fullRevoke := ctx.Bool("all")
+
+	// Create a new MinIO Admin Client
+	client, err := newAdminClient(aliasedURL)
+	fatalIf(err, "Unable to initialize admin connection.")
+
+	e := client.RevokeTokens(globalContext, madmin.RevokeTokensReq{
+		User:            user,
+		TokenRevokeType: tokenRevokeType,
+		FullRevoke:      fullRevoke,
+	})
+	fatalIf(probe.NewError(e).Trace(args...), "Unable to revoke tokens for %s", user)
+
+	printMsg(stsRevokeMessage{
+		User:            user,
+		TokenRevokeType: tokenRevokeType,
+	})
+
+	return nil
+}
diff --git a/cmd/idp-ldap-accesskey.go b/cmd/idp-ldap-accesskey.go
index a0f17516..63da92ef 100644
--- a/cmd/idp-ldap-accesskey.go
+++ b/cmd/idp-ldap-accesskey.go
@@ -28,6 +28,7 @@ var idpLdapAccesskeySubcommands = []cli.Command{
 	idpLdapAccesskeyEditCmd,
 	idpLdapAccesskeyEnableCmd,
 	idpLdapAccesskeyDisableCmd,
+	idpLdapAccesskeySTSRevokeCmd,
 }
 
 var idpLdapAccesskeyCmd = cli.Command{
diff --git a/cmd/mv-main.go b/cmd/mv-main.go
index 76103b2b..3e051d67 100644
--- a/cmd/mv-main.go
+++ b/cmd/mv-main.go
@@ -210,8 +210,9 @@ func mainMove(cliCtx *cli.Context) error {
 		args := cliCtx.Args()
 		srcURL := args.Get(0)
 		dstURL := args.Get(1)
-		if srcURL == dstURL {
-			fatalIf(errDummy().Trace(), fmt.Sprintf("Source and destination urls cannot be the same: %v.", srcURL))
+		if isURLPrefix(srcURL, dstURL) {
+			fatalIf(errDummy().Trace(), fmt.Sprintf("The source %v and destination %v cannot be subdirectories of each other", srcURL, dstURL))
+			return nil
 		}
 	}
 
diff --git a/go.mod b/go.mod
index 1ab5af01..72b87933 100644
--- a/go.mod
+++ b/go.mod
@@ -23,7 +23,7 @@ require (
 	github.com/minio/cli v1.24.2
 	github.com/minio/colorjson v1.0.8
 	github.com/minio/filepath v1.0.0
-	github.com/minio/madmin-go/v3 v3.0.97
+	github.com/minio/madmin-go/v3 v3.0.102
 	github.com/minio/minio-go/v7 v7.0.88
 	github.com/minio/pkg/v3 v3.1.0
 	github.com/minio/selfupdate v0.6.0
diff --git a/go.sum b/go.sum
index 428b8bb7..b9f73ddb 100644
--- a/go.sum
+++ b/go.sum
@@ -142,8 +142,8 @@ github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY
 github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
 github.com/minio/filepath v1.0.0 h1:fvkJu1+6X+ECRA6G3+JJETj4QeAYO9sV43I79H8ubDY=
 github.com/minio/filepath v1.0.0/go.mod h1:/nRZA2ldl5z6jT9/KQuvZcQlxZIMQoFFQPvEXx9T/Bw=
-github.com/minio/madmin-go/v3 v3.0.97 h1:P7XO+ofPexkXy9akxG9Xoif1zIkbmWKeKtG3AquVzEY=
-github.com/minio/madmin-go/v3 v3.0.97/go.mod h1:pMLdj9OtN0CANNs5tdm6opvOlDFfj0WhbztboZAjRWE=
+github.com/minio/madmin-go/v3 v3.0.102 h1:bqy15D6d9uQOh/3B/sLfzRtWSJgZeuKnAI5VRDhvRQw=
+github.com/minio/madmin-go/v3 v3.0.102/go.mod h1:pMLdj9OtN0CANNs5tdm6opvOlDFfj0WhbztboZAjRWE=
 github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
 github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
 github.com/minio/minio-go/v7 v7.0.88 h1:v8MoIJjwYxOkehp+eiLIuvXk87P2raUtoU5klrAAshs=



Test patch:
diff --git a/cmd/client-url_test.go b/cmd/client-url_test.go
index 2e1ac23f..1f7dd37e 100644
--- a/cmd/client-url_test.go
+++ b/cmd/client-url_test.go
@@ -17,7 +17,11 @@
 
 package cmd
 
-import checkv1 "gopkg.in/check.v1"
+import (
+	"testing"
+
+	checkv1 "gopkg.in/check.v1"
+)
 
 // TestURL - tests url parsing and fields.
 func (s *TestSuite) TestURL(c *checkv1.C) {
@@ -52,3 +56,33 @@ func (s *TestSuite) TestURLJoinPath(c *checkv1.C) {
 	url = urlJoinPath(url1, url2)
 	c.Assert(url, checkv1.Equals, "http://s3.mycompany.io/dev/mybucket/bin/")
 }
+
+func Test_isURLPrefix(t *testing.T) {
+	type args struct {
+		src  string
+		dest string
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{
+		{"test1", args{"s3/test", "s3/test/test"}, true},
+		{"test2", args{"s3/test/", "s3/test/test"}, true},
+		{"test3", args{"s3/test/test", "s3/test/"}, true},
+		{"test4", args{"s3/test/test", "s3/test/test.123"}, false},
+		{"test5", args{"s3/test/", "s3/test/test/test/test"}, true},
+		{"test6", args{"s3/test/*", "s3/test/test/"}, true},
+		{"test7", args{"s3/test/*", "s3/test1/test/"}, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := isURLPrefix(tt.args.src, tt.args.dest); got != tt.want {
+				t.Errorf("isURLPrefix() = %v, want %v", got, tt.want)
+			}
+			if got := isURLPrefix(tt.args.dest, tt.args.src); got != tt.want {
+				t.Errorf("isURLPrefix() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}