Skip to content

Commit 5b840fe

Browse files
SNOW-1905965: Add wildcard support in file ls (#606)
* SNOW-1905965: Add wildcard support in `file ls` Added a feature that will make the following ls patterns possible: * '*' matches any sequence of non-Separator characters * '?' matches any single non-Separator character * '[aml]' matches any single character within the brackets * '[a-f]' matches any single character within the range specified in the brackets * SNOW-1905965: Split path and pattern code paths * SNOW-1905965: Add glob pattern support info to help
1 parent b3bfe5e commit 5b840fe

File tree

3 files changed

+148
-8
lines changed

3 files changed

+148
-8
lines changed

services/localfile/client/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,7 @@ func (*lsCmd) Usage() string {
917917
return `ls [--long] [--directory] <path>:
918918
List the path given printing out each entry (N if it's a directory). Use --long to get ls -l style output.
919919
If the entry is a directory it will be suppressed from the output unless --directory is set (ls -d style).
920+
The command supports glob patterns (*?[a-z]).
920921
NOTE: Only expands one level of a directory.
921922
`
922923
}

services/localfile/server/localfile.go

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -674,8 +674,12 @@ func (s *server) Copy(ctx context.Context, req *pb.CopyRequest) (_ *emptypb.Empt
674674
return &emptypb.Empty{}, nil
675675
}
676676

677+
// isGlobPattern checks if a path contains glob pattern characters.
678+
func isGlobPattern(path string) bool {
679+
return strings.ContainsAny(path, "*?[")
680+
}
681+
677682
func (s *server) listFor(entry string, ctx context.Context, consumer func(*pb.StatReply) error) error {
678-
logger := logr.FromContextOrDiscard(ctx)
679683
recorder := metrics.RecorderFromContextOrNoop(ctx)
680684
if entry == "" {
681685
recorder.CounterOrLog(ctx, localfileListFailureCounter, 1, attribute.String("reason", "missing_entry"))
@@ -686,9 +690,49 @@ func (s *server) listFor(entry string, ctx context.Context, consumer func(*pb.St
686690
return err
687691
}
688692

689-
// We always send back the entry first.
690-
logger.Info("ls", "filename", entry)
691-
resp, err := osStat(entry, false)
693+
if isGlobPattern(entry) {
694+
return s.listGlob(entry, ctx, consumer)
695+
}
696+
return s.listPath(entry, ctx, consumer)
697+
}
698+
699+
// listGlob lists files matching a glob pattern. Does not descend into directories.
700+
func (s *server) listGlob(pattern string, ctx context.Context, consumer func(*pb.StatReply) error) error {
701+
logger := logr.FromContextOrDiscard(ctx)
702+
recorder := metrics.RecorderFromContextOrNoop(ctx)
703+
704+
matches, err := filepath.Glob(pattern)
705+
if err != nil {
706+
recorder.CounterOrLog(ctx, localfileListFailureCounter, 1, attribute.String("reason", "glob_err"))
707+
return status.Errorf(codes.InvalidArgument, "invalid pattern: %v", err)
708+
}
709+
if len(matches) == 0 {
710+
recorder.CounterOrLog(ctx, localfileListFailureCounter, 1, attribute.String("reason", "no_matches_err"))
711+
return status.Errorf(codes.NotFound, "no matches found for pattern %s", pattern)
712+
}
713+
714+
for _, match := range matches {
715+
logger.Info("ls", "glob-match", match)
716+
resp, err := osStat(match, false)
717+
if err != nil {
718+
recorder.CounterOrLog(ctx, localfileListFailureCounter, 1, attribute.String("reason", "stat_err"))
719+
return err
720+
}
721+
if err := consumer(resp); err != nil {
722+
recorder.CounterOrLog(ctx, localfileListFailureCounter, 1, attribute.String("reason", "send_err"))
723+
return status.Errorf(codes.Internal, "list: send error %v", err)
724+
}
725+
}
726+
return nil
727+
}
728+
729+
// listPath lists a specific path. If the path is a directory, its contents are listed (one level).
730+
func (s *server) listPath(path string, ctx context.Context, consumer func(*pb.StatReply) error) error {
731+
logger := logr.FromContextOrDiscard(ctx)
732+
recorder := metrics.RecorderFromContextOrNoop(ctx)
733+
734+
logger.Info("ls", "filename", path)
735+
resp, err := osStat(path, false)
692736
if err != nil {
693737
recorder.CounterOrLog(ctx, localfileListFailureCounter, 1, attribute.String("reason", "stat_err"))
694738
return err
@@ -698,16 +742,15 @@ func (s *server) listFor(entry string, ctx context.Context, consumer func(*pb.St
698742
return status.Errorf(codes.Internal, "list: send error %v", err)
699743
}
700744

701-
// If it's directory we'll open it and go over its entries.
745+
// If path is a directory, list its contents (one level only).
702746
if fs.FileMode(resp.Mode).IsDir() {
703-
entries, err := os.ReadDir(entry)
747+
entries, err := os.ReadDir(path)
704748
if err != nil {
705749
recorder.CounterOrLog(ctx, localfileListFailureCounter, 1, attribute.String("reason", "read_dir_err"))
706750
return status.Errorf(codes.Internal, "readdir: %v", err)
707751
}
708-
// Only do one level so iterate these and we're done.
709752
for _, e := range entries {
710-
name := filepath.Join(entry, e.Name())
753+
name := filepath.Join(path, e.Name())
711754
logger.Info("ls", "filename", name)
712755
// Use lstat so that we don't return misleading directory contents from
713756
// following symlinks.

services/localfile/server/localfile_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,25 @@ func TestList(t *testing.T) {
10981098
symStat, err := osStat(symlink, true)
10991099
testutil.FatalOnErr("osStat", err, t)
11001100

1101+
// Create additional files for glob testing
1102+
globDir := t.TempDir()
1103+
logFile1, err := os.Create(filepath.Join(globDir, "app.log"))
1104+
testutil.FatalOnErr("os.Create", err, t)
1105+
logFile1.Close()
1106+
logFile2, err := os.Create(filepath.Join(globDir, "error.log"))
1107+
testutil.FatalOnErr("os.Create", err, t)
1108+
logFile2.Close()
1109+
txtFile, err := os.Create(filepath.Join(globDir, "readme.txt"))
1110+
testutil.FatalOnErr("os.Create", err, t)
1111+
txtFile.Close()
1112+
1113+
logFile1Stat, err := osStat(filepath.Join(globDir, "app.log"), true)
1114+
testutil.FatalOnErr("osStat", err, t)
1115+
logFile2Stat, err := osStat(filepath.Join(globDir, "error.log"), true)
1116+
testutil.FatalOnErr("osStat", err, t)
1117+
txtFileStat, err := osStat(filepath.Join(globDir, "readme.txt"), true)
1118+
testutil.FatalOnErr("osStat", err, t)
1119+
11011120
// Construct a directory with no perms. We should be able
11021121
// to stat this but then fail to readdir on it.
11031122
badDir := filepath.Join(t.TempDir(), "/foo")
@@ -1173,6 +1192,83 @@ func TestList(t *testing.T) {
11731192
},
11741193
wantErr: true,
11751194
},
1195+
{
1196+
name: "glob pattern matching log files",
1197+
req: &pb.ListRequest{
1198+
Entry: filepath.Join(globDir, "*.log"),
1199+
},
1200+
expected: []*pb.StatReply{
1201+
logFile1Stat,
1202+
logFile2Stat,
1203+
},
1204+
},
1205+
{
1206+
name: "glob pattern matching txt files",
1207+
req: &pb.ListRequest{
1208+
Entry: filepath.Join(globDir, "*.txt"),
1209+
},
1210+
expected: []*pb.StatReply{
1211+
txtFileStat,
1212+
},
1213+
},
1214+
{
1215+
name: "glob pattern with no matches",
1216+
req: &pb.ListRequest{
1217+
Entry: filepath.Join(globDir, "*.xyz"),
1218+
},
1219+
wantErr: true,
1220+
},
1221+
{
1222+
name: "glob pattern non-absolute path",
1223+
req: &pb.ListRequest{
1224+
Entry: "relative/*.log",
1225+
},
1226+
wantErr: true,
1227+
},
1228+
{
1229+
name: "glob pattern with directory traversal",
1230+
req: &pb.ListRequest{
1231+
Entry: "/tmp/../tmp/*.log",
1232+
},
1233+
wantErr: true,
1234+
},
1235+
{
1236+
name: "glob pattern matching all files",
1237+
req: &pb.ListRequest{
1238+
Entry: filepath.Join(globDir, "*"),
1239+
},
1240+
expected: []*pb.StatReply{
1241+
logFile1Stat,
1242+
logFile2Stat,
1243+
txtFileStat,
1244+
},
1245+
},
1246+
{
1247+
name: "glob pattern with question mark",
1248+
req: &pb.ListRequest{
1249+
Entry: filepath.Join(globDir, "???.log"),
1250+
},
1251+
expected: []*pb.StatReply{
1252+
logFile1Stat,
1253+
},
1254+
},
1255+
{
1256+
name: "glob pattern with bracket",
1257+
req: &pb.ListRequest{
1258+
Entry: filepath.Join(globDir, "*[om]*.*"),
1259+
},
1260+
expected: []*pb.StatReply{
1261+
logFile2Stat,
1262+
txtFileStat,
1263+
},
1264+
},
1265+
{
1266+
name: "broken pattern",
1267+
req: &pb.ListRequest{
1268+
Entry: filepath.Join(globDir, "[a"),
1269+
},
1270+
wantErr: true,
1271+
},
11761272
} {
11771273
tc := tc
11781274
t.Run(tc.name, func(t *testing.T) {

0 commit comments

Comments
 (0)