github.com/minio/mc@v0.0.0-20240503112107-b471de8d1882/cmd/find.go (about)

     1  // Copyright (c) 2015-2022 MinIO, Inc.
     2  //
     3  // This file is part of MinIO Object Storage stack
     4  //
     5  // This program is free software: you can redistribute it and/or modify
     6  // it under the terms of the GNU Affero General Public License as published by
     7  // the Free Software Foundation, either version 3 of the License, or
     8  // (at your option) any later version.
     9  //
    10  // This program is distributed in the hope that it will be useful
    11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  // GNU Affero General Public License for more details.
    14  //
    15  // You should have received a copy of the GNU Affero General Public License
    16  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  
    18  package cmd
    19  
    20  import (
    21  	"bytes"
    22  	"context"
    23  	"fmt"
    24  	"os"
    25  	"os/exec"
    26  	"path/filepath"
    27  	"regexp"
    28  	"strconv"
    29  	"strings"
    30  	"syscall"
    31  	"time"
    32  
    33  	"github.com/dustin/go-humanize"
    34  	"github.com/google/shlex"
    35  	"github.com/minio/cli"
    36  	"github.com/minio/mc/pkg/probe"
    37  	"github.com/minio/pkg/v2/console"
    38  
    39  	// golang does not support flat keys for path matching, find does
    40  	"github.com/minio/pkg/v2/wildcard"
    41  )
    42  
    43  // findMessage holds JSON and string values for printing find command output.
    44  type findMessage struct {
    45  	contentMessage
    46  }
    47  
    48  // String calls tells the console what to print and how to print it.
    49  func (f findMessage) String() string {
    50  	var msg string
    51  	msg += f.contentMessage.Key
    52  	if f.VersionID != "" {
    53  		msg += " (" + f.contentMessage.VersionID + ")"
    54  	}
    55  	return console.Colorize("Find", msg)
    56  }
    57  
    58  // JSON formats output to be JSON output.
    59  func (f findMessage) JSON() string {
    60  	return f.contentMessage.JSON()
    61  }
    62  
    63  // nameMatch is similar to filepath.Match but only matches the
    64  // base path of the input, if we couldn't find a match we
    65  // also proceed to look for similar strings alone and print it.
    66  //
    67  // pattern:
    68  //
    69  //	{ term }
    70  //
    71  // term:
    72  //
    73  //	'*'         matches any sequence of non-Separator characters
    74  //	'?'         matches any single non-Separator character
    75  //	'[' [ '^' ] { character-range } ']'
    76  //	            character class (must be non-empty)
    77  //	c           matches character c (c != '*', '?', '\\', '[')
    78  //	'\\' c      matches character c
    79  //
    80  // character-range:
    81  //
    82  //	c           matches character c (c != '\\', '-', ']')
    83  //	'\\' c      matches character c
    84  //	lo '-' hi   matches character c for lo <= c <= hi
    85  func nameMatch(pattern, path string) bool {
    86  	matched, e := filepath.Match(pattern, filepath.Base(path))
    87  	errorIf(probe.NewError(e).Trace(pattern, path), "Unable to match with input pattern.")
    88  	if !matched {
    89  		for _, pathComponent := range strings.Split(path, "/") {
    90  			matched = pathComponent == pattern
    91  			if matched {
    92  				break
    93  			}
    94  		}
    95  	}
    96  	return matched
    97  }
    98  
    99  func patternMatch(pattern, match string) bool {
   100  	pattern = strings.ToLower(pattern)
   101  	match = strings.ToLower(match)
   102  	return wildcard.Match(pattern, match)
   103  }
   104  
   105  // pathMatch reports whether path matches the wildcard pattern.
   106  // supports  '*' and '?' wildcards in the pattern string.
   107  // unlike path.Match(), considers a path as a flat name space
   108  // while matching the pattern. The difference is illustrated in
   109  // the example here https://play.golang.org/p/Ega9qgD4Qz .
   110  func pathMatch(pattern, path string) bool {
   111  	return wildcard.Match(pattern, path)
   112  }
   113  
   114  func getExitStatus(err error) int {
   115  	if err == nil {
   116  		return 0
   117  	}
   118  	if pe, ok := err.(*exec.ExitError); ok {
   119  		if es, ok := pe.ProcessState.Sys().(syscall.WaitStatus); ok {
   120  			return es.ExitStatus()
   121  		}
   122  	}
   123  	return 1
   124  }
   125  
   126  // execFind executes the input command line, additionally formats input
   127  // for the command line in accordance with subsititution arguments.
   128  func execFind(ctx context.Context, args string, fileContent contentMessage) {
   129  	split, err := shlex.Split(args)
   130  	if err != nil {
   131  		console.Println(console.Colorize("FindExecErr", "Unable to parse --exec: "+err.Error()))
   132  		os.Exit(getExitStatus(err))
   133  	}
   134  	if len(split) == 0 {
   135  		return
   136  	}
   137  	for i, arg := range split {
   138  		split[i] = stringsReplace(ctx, arg, fileContent)
   139  	}
   140  	cmd := exec.Command(split[0], split[1:]...)
   141  	var out bytes.Buffer
   142  	var stderr bytes.Buffer
   143  	cmd.Stdout = &out
   144  	cmd.Stderr = &stderr
   145  	if err := cmd.Run(); err != nil {
   146  		if stderr.Len() > 0 {
   147  			console.Println(console.Colorize("FindExecErr", strings.TrimSpace(stderr.String())))
   148  		}
   149  		console.Println(console.Colorize("FindExecErr", err.Error()))
   150  		// Return exit status of the command run
   151  		os.Exit(getExitStatus(err))
   152  	}
   153  	console.PrintC(out.String())
   154  }
   155  
   156  // watchFind - enables listening on the input path, listens for all file/object
   157  // created actions. Asynchronously executes the input command line, also allows
   158  // formatting for the command line in accordance with subsititution arguments.
   159  func watchFind(ctxCtx context.Context, ctx *findContext) {
   160  	// Watch is not enabled, return quickly.
   161  	if !ctx.watch {
   162  		return
   163  	}
   164  	options := WatchOptions{
   165  		Recursive: true,
   166  		Events:    []string{"put"},
   167  	}
   168  	watchObj, err := ctx.clnt.Watch(ctxCtx, options)
   169  	fatalIf(err.Trace(ctx.targetAlias), "Unable to watch with given options.")
   170  
   171  	// Loop until user CTRL-C the command line.
   172  	for {
   173  		select {
   174  		case <-globalContext.Done():
   175  			console.Println()
   176  			close(watchObj.DoneChan)
   177  			return
   178  		case events, ok := <-watchObj.Events():
   179  			if !ok {
   180  				return
   181  			}
   182  
   183  			for _, event := range events {
   184  				time, e := time.Parse(time.RFC3339, event.Time)
   185  				if e != nil {
   186  					errorIf(probe.NewError(e).Trace(event.Time), "Unable to parse event time.")
   187  					continue
   188  				}
   189  
   190  				find(ctxCtx, ctx, contentMessage{
   191  					Key:  getAliasedPath(ctx, event.Path),
   192  					Time: time,
   193  					Size: event.Size,
   194  				})
   195  			}
   196  		case err, ok := <-watchObj.Errors():
   197  			if !ok {
   198  				return
   199  			}
   200  			errorIf(err, "Unable to watch for events.")
   201  			return
   202  		}
   203  	}
   204  }
   205  
   206  // Descend at most (a non-negative integer) levels of files
   207  // below the starting-prefix and trims the suffix. This function
   208  // returns path as is without manipulation if the maxDepth is 0
   209  // i.e (not set).
   210  func trimSuffixAtMaxDepth(startPrefix, path, separator string, maxDepth uint) string {
   211  	if maxDepth == 0 {
   212  		return path
   213  	}
   214  	// Remove the requested prefix from consideration, maxDepth is
   215  	// only considered for all other levels excluding the starting prefix.
   216  	path = strings.TrimPrefix(path, startPrefix)
   217  	pathComponents := strings.SplitAfter(path, separator)
   218  	if len(pathComponents) >= int(maxDepth) {
   219  		pathComponents = pathComponents[:maxDepth]
   220  	}
   221  	pathComponents = append([]string{startPrefix}, pathComponents...)
   222  	return strings.Join(pathComponents, "")
   223  }
   224  
   225  // Get aliased path used finally in printing, trim paths to ensure
   226  // that we have removed the fully qualified paths and original
   227  // start prefix (targetAlias) is retained. This function also honors
   228  // maxDepth if set then the resultant path will be trimmed at requested
   229  // maxDepth.
   230  func getAliasedPath(ctx *findContext, path string) string {
   231  	separator := string(ctx.clnt.GetURL().Separator)
   232  	prefixPath := ctx.clnt.GetURL().String()
   233  	var aliasedPath string
   234  	if ctx.targetAlias != "" {
   235  		aliasedPath = ctx.targetAlias + strings.TrimPrefix(path, strings.TrimSuffix(ctx.targetFullURL, separator))
   236  	} else {
   237  		aliasedPath = path
   238  		// look for prefix path, if found filter at that, Watch calls
   239  		// for example always provide absolute path. So for relative
   240  		// prefixes we need to employ this kind of code.
   241  		if i := strings.Index(path, prefixPath); i > 0 {
   242  			aliasedPath = path[i:]
   243  		}
   244  	}
   245  	return trimSuffixAtMaxDepth(ctx.targetURL, aliasedPath, separator, ctx.maxDepth)
   246  }
   247  
   248  func find(ctxCtx context.Context, ctx *findContext, fileContent contentMessage) {
   249  	// Match the incoming content, didn't match return.
   250  	if !matchFind(ctx, fileContent) {
   251  		return
   252  	} // For all matching content
   253  
   254  	// proceed to either exec, format the output string.
   255  	if ctx.execCmd != "" {
   256  		execFind(ctxCtx, ctx.execCmd, fileContent)
   257  		return
   258  	}
   259  	if ctx.printFmt != "" {
   260  		fileContent.Key = stringsReplace(ctxCtx, ctx.printFmt, fileContent)
   261  	}
   262  	printMsg(findMessage{fileContent})
   263  }
   264  
   265  // doFind - find is main function body which interprets and executes
   266  // all the input parameters.
   267  func doFind(ctxCtx context.Context, ctx *findContext) error {
   268  	// If watch is enabled we will wait on the prefix perpetually
   269  	// for all I/O events until canceled by user, if watch is not enabled
   270  	// following defer is a no-op.
   271  	defer watchFind(ctxCtx, ctx)
   272  
   273  	lstOptions := ListOptions{
   274  		WithOlderVersions: ctx.withOlderVersions,
   275  		WithDeleteMarkers: false,
   276  		Recursive:         true,
   277  		ShowDir:           DirFirst,
   278  		WithMetadata:      len(ctx.matchMeta) > 0 || len(ctx.matchTags) > 0,
   279  	}
   280  
   281  	// iterate over all content which is within the given directory
   282  	for content := range ctx.clnt.List(globalContext, lstOptions) {
   283  		if content.Err != nil {
   284  			switch content.Err.ToGoError().(type) {
   285  			// handle this specifically for filesystem related errors.
   286  			case BrokenSymlink:
   287  				errorIf(content.Err.Trace(ctx.clnt.GetURL().String()), "Unable to list broken link.")
   288  				continue
   289  			case TooManyLevelsSymlink:
   290  				errorIf(content.Err.Trace(ctx.clnt.GetURL().String()), "Unable to list too many levels link.")
   291  				continue
   292  			case PathNotFound:
   293  				errorIf(content.Err.Trace(ctx.clnt.GetURL().String()), "Unable to list folder.")
   294  				continue
   295  			case PathInsufficientPermission:
   296  				errorIf(content.Err.Trace(ctx.clnt.GetURL().String()), "Unable to list folder.")
   297  				continue
   298  			}
   299  			fatalIf(content.Err.Trace(ctx.clnt.GetURL().String()), "Unable to list folder.")
   300  			continue
   301  		}
   302  		if content.StorageClass == s3StorageClassGlacier {
   303  			continue
   304  		}
   305  
   306  		fileKeyName := getAliasedPath(ctx, content.URL.String())
   307  		fileContent := contentMessage{
   308  			Key:       fileKeyName,
   309  			VersionID: content.VersionID,
   310  			Time:      content.Time.Local(),
   311  			Size:      content.Size,
   312  			Metadata:  content.UserMetadata,
   313  			Tags:      content.Tags,
   314  		}
   315  
   316  		// Match the incoming content, didn't match return.
   317  		if !matchFind(ctx, fileContent) {
   318  			continue
   319  		} // For all matching content
   320  
   321  		// proceed to either exec, format the output string.
   322  		if ctx.execCmd != "" {
   323  			execFind(ctxCtx, ctx.execCmd, fileContent)
   324  			continue
   325  		}
   326  		if ctx.printFmt != "" {
   327  			fileContent.Key = stringsReplace(ctxCtx, ctx.printFmt, fileContent)
   328  		}
   329  
   330  		printMsg(findMessage{fileContent})
   331  	}
   332  
   333  	// Success, notice watch will execute in defer only if enabled and this call
   334  	// will return after watch is canceled.
   335  	return nil
   336  }
   337  
   338  // stringsReplace - formats the string to remove {} and replace each
   339  // with the appropriate argument
   340  func stringsReplace(ctx context.Context, args string, fileContent contentMessage) string {
   341  	// replace all instances of {}
   342  	str := args
   343  
   344  	str = strings.ReplaceAll(str, "{}", fileContent.Key)
   345  
   346  	// replace all instances of {""}
   347  	str = strings.ReplaceAll(str, `{""}`, strconv.Quote(fileContent.Key))
   348  
   349  	// replace all instances of {base}
   350  	str = strings.ReplaceAll(str, "{base}", filepath.Base(fileContent.Key))
   351  
   352  	// replace all instances of {"base"}
   353  	str = strings.ReplaceAll(str, `{"base"}`, strconv.Quote(filepath.Base(fileContent.Key)))
   354  
   355  	// replace all instances of {dir}
   356  	str = strings.ReplaceAll(str, "{dir}", filepath.Dir(fileContent.Key))
   357  
   358  	// replace all instances of {"dir"}
   359  	str = strings.ReplaceAll(str, `{"dir"}`, strconv.Quote(filepath.Dir(fileContent.Key)))
   360  
   361  	// replace all instances of {size}
   362  	str = strings.ReplaceAll(str, "{size}", humanize.IBytes(uint64(fileContent.Size)))
   363  
   364  	// replace all instances of {"size"}
   365  	str = strings.ReplaceAll(str, `{"size"}`, strconv.Quote(humanize.IBytes(uint64(fileContent.Size))))
   366  
   367  	// replace all instances of {time}
   368  	str = strings.ReplaceAll(str, "{time}", fileContent.Time.Format(printDate))
   369  
   370  	// replace all instances of {"time"}
   371  	str = strings.ReplaceAll(str, `{"time"}`, strconv.Quote(fileContent.Time.Format(printDate)))
   372  
   373  	// replace all instances of {url}
   374  	if strings.Contains(str, "{url}") {
   375  		str = strings.ReplaceAll(str, "{url}", getShareURL(ctx, fileContent.Key))
   376  	}
   377  
   378  	// replace all instances of {"url"}
   379  	if strings.Contains(str, `{"url"}`) {
   380  		str = strings.ReplaceAll(str, `{"url"}`, strconv.Quote(getShareURL(ctx, fileContent.Key)))
   381  	}
   382  
   383  	// replace all instances of {version}
   384  	str = strings.ReplaceAll(str, `{version}`, fileContent.VersionID)
   385  
   386  	// replace all instances of {"version"}
   387  	str = strings.ReplaceAll(str, `{"version"}`, strconv.Quote(fileContent.VersionID))
   388  
   389  	return str
   390  }
   391  
   392  // matchFind matches whether fileContent matches appropriately with standard
   393  // "pattern matching" flags requested by the user, such as "name", "path", "regex" ..etc.
   394  func matchFind(ctx *findContext, fileContent contentMessage) (match bool) {
   395  	match = true
   396  	prefixPath := ctx.targetURL
   397  	// Add separator only if targetURL doesn't already have separator.
   398  	if !strings.HasPrefix(prefixPath, string(ctx.clnt.GetURL().Separator)) {
   399  		prefixPath = ctx.targetURL + string(ctx.clnt.GetURL().Separator)
   400  	}
   401  	// Trim the prefix such that we will apply file path matching techniques
   402  	// on path excluding the starting prefix.
   403  	path := strings.TrimPrefix(fileContent.Key, prefixPath)
   404  	if match && ctx.ignorePattern != "" {
   405  		match = !pathMatch(ctx.ignorePattern, path)
   406  	}
   407  	if match && ctx.namePattern != "" {
   408  		match = nameMatch(ctx.namePattern, path)
   409  	}
   410  	if match && ctx.pathPattern != "" {
   411  		match = pathMatch(ctx.pathPattern, path)
   412  	}
   413  	if match && ctx.regexPattern != nil {
   414  		match = ctx.regexPattern.MatchString(path)
   415  	}
   416  	if match && ctx.olderThan != "" {
   417  		match = !isOlder(fileContent.Time, ctx.olderThan)
   418  	}
   419  	if match && ctx.newerThan != "" {
   420  		match = !isNewer(fileContent.Time, ctx.newerThan)
   421  	}
   422  	if match && ctx.largerSize > 0 {
   423  		match = int64(ctx.largerSize) < fileContent.Size
   424  	}
   425  	if match && ctx.smallerSize > 0 {
   426  		match = int64(ctx.smallerSize) > fileContent.Size
   427  	}
   428  	if match && len(ctx.matchMeta) > 0 {
   429  		match = matchRegexMaps(ctx.matchMeta, fileContent.Metadata)
   430  	}
   431  	if match && len(ctx.matchTags) > 0 {
   432  		match = matchRegexMaps(ctx.matchTags, fileContent.Tags)
   433  	}
   434  	return match
   435  }
   436  
   437  // 7 days in seconds.
   438  var defaultSevenDays = time.Duration(604800) * time.Second
   439  
   440  // getShareURL is used in conjunction with the {url} substitution
   441  // argument to generate and return presigned URLs, returns error if any.
   442  func getShareURL(ctx context.Context, path string) string {
   443  	targetAlias, targetURLFull, _, err := expandAlias(path)
   444  	fatalIf(err.Trace(path), "Unable to expand alias.")
   445  
   446  	clnt, err := newClientFromAlias(targetAlias, targetURLFull)
   447  	fatalIf(err.Trace(targetAlias, targetURLFull), "Unable to initialize client instance from alias.")
   448  
   449  	content, err := clnt.Stat(ctx, StatOptions{})
   450  	fatalIf(err.Trace(targetURLFull, targetAlias), "Unable to lookup file/object.")
   451  
   452  	// Skip if it is a directory.
   453  	if content.Type.IsDir() {
   454  		return ""
   455  	}
   456  
   457  	objectURL := content.URL.String()
   458  	newClnt, err := newClientFromAlias(targetAlias, objectURL)
   459  	fatalIf(err.Trace(targetAlias, objectURL), "Unable to initialize new client from alias.")
   460  
   461  	// Set default expiry for each url (point of no longer valid), to be 7 days
   462  	shareURL, err := newClnt.ShareDownload(ctx, "", defaultSevenDays)
   463  	fatalIf(err.Trace(targetAlias, objectURL), "Unable to generate share url.")
   464  
   465  	return shareURL
   466  }
   467  
   468  // getRegexMap returns a map from the StringSlice key.
   469  // Each entry must be key=regex.
   470  // Will exit with error if an un-parsable entry is found.
   471  func getRegexMap(cliCtx *cli.Context, key string) map[string]*regexp.Regexp {
   472  	sl := cliCtx.StringSlice(key)
   473  	if len(sl) == 0 {
   474  		return nil
   475  	}
   476  	reMap := make(map[string]*regexp.Regexp, len(sl))
   477  	for _, v := range sl {
   478  		split := strings.SplitN(v, "=", 2)
   479  		if len(split) < 2 {
   480  			err := probe.NewError(fmt.Errorf("want one = separator, got none"))
   481  			fatalIf(err.Trace(v), "Unable to split key+value. Must be key=regex")
   482  		}
   483  		// No value means it should not exist or be empty.
   484  		if len(split[1]) == 0 {
   485  			reMap[split[0]] = nil
   486  			continue
   487  		}
   488  		var err error
   489  		reMap[split[0]], err = regexp.Compile(split[1])
   490  		if err != nil {
   491  			fatalIf(probe.NewError(err), fmt.Sprintf("Unable to compile metadata regex for %s=%s", split[0], split[1]))
   492  		}
   493  	}
   494  	return reMap
   495  }
   496  
   497  // matchRegexMaps will check if all regexes in 'm' match values in 'v' with the same key.
   498  // If a regex is nil, it must either not exist in v or have a 0 length value.
   499  func matchRegexMaps(m map[string]*regexp.Regexp, v map[string]string) bool {
   500  	for k, reg := range m {
   501  		if reg == nil {
   502  			if v[k] != "" {
   503  				return false
   504  			}
   505  			// Does not exist or empty, that is fine.
   506  			continue
   507  		}
   508  		val, ok := v[k]
   509  		if !ok || !reg.MatchString(val) {
   510  			return false
   511  		}
   512  	}
   513  	return true
   514  }