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 }