github.com/benchkram/bob@v0.0.0-20240314204020-b7a57f2f9be9/pkg/filepathutil/list.go (about)

     1  package filepathutil
     2  
     3  import (
     4  	"fmt"
     5  	"io/fs"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  
    10  	"github.com/benchkram/bob/pkg/filepathxx"
    11  	"github.com/logrusorgru/aurora"
    12  )
    13  
    14  // DefaultIgnores
    15  var (
    16  	DefaultIgnores = map[string]bool{
    17  		"node_modules": true,
    18  		".git":         true,
    19  	}
    20  )
    21  
    22  //var listRecursiveCache = make(map[string][]string, 1024)
    23  
    24  func ClearListRecursiveCache() {
    25  	// listRecursiveCache = make(map[string][]string, 1024)
    26  }
    27  
    28  // ListRecursive lists all files relative to input. It ignores symbolic links
    29  // which are not inside the projectRoot.
    30  func ListRecursive(inp string, projectRoot string) (all []string, err error) {
    31  	// if result, ok := listRecursiveCache[inp]; ok {
    32  	// 	return result, nil
    33  	// }
    34  
    35  	// FIXME: when "*" is passed as input it's likely to hit the cache
    36  	// as there is no further information. Think how to handle the cache correctly
    37  	// in those cases. For now the cache is disabled!
    38  
    39  	// FIXME: new list recursive
    40  	// * does input contain a glob? see https://pkg.go.dev/path/filepath#Match => read with filepathx.Glob
    41  	// * check if input is a file => add file
    42  	// * check if input is a dir => add files in dir recursively
    43  	//
    44  	// More input:
    45  	// https://github.com/iriri/minimal/blob/9b2348d09c1ab2c25505f9933a3591ef9db6522a/gitignore/gitignore.go#L245
    46  	// https://github.com/zabawaba99/go-gitignore/
    47  	// https://github.com/gobwas/glob
    48  	//
    49  	// Thoughts: Is it possible to compile a ignoreList upfront?
    50  	// Then check if the accessed file || dir can be skipped.
    51  	// Maybe it's even possible to call skipdir on a walk func.
    52  
    53  	// symLinkError are gathered here and printed at the end of
    54  	// the function to stdout.
    55  	symlinkErrors := []error{}
    56  
    57  	// FIXME: possibly ignore here too, before calling listDir
    58  	if s, err := os.Lstat(inp); err != nil || !s.IsDir() {
    59  		// File
    60  
    61  		// Use glob for unknowns (wildcard-paths) and existing files (non-dirs)
    62  		matches, err := filepathxx.Glob(inp)
    63  		if err != nil {
    64  			return nil, fmt.Errorf("failed to glob %q: %w", inp, err)
    65  		}
    66  
    67  		for _, m := range matches {
    68  			s, err := os.Lstat(m)
    69  			if err == nil && !s.IsDir() {
    70  				isValid, err := isValidFile(m, s, projectRoot)
    71  				if err != nil {
    72  					symlinkErrors = append(symlinkErrors, err)
    73  				}
    74  
    75  				if !isValid {
    76  					continue
    77  				}
    78  
    79  				// Existing file
    80  				all = append(all, m)
    81  			} else {
    82  				// Directory
    83  				files, symErrors, err := listDir(m, projectRoot)
    84  				if err != nil {
    85  					return nil, fmt.Errorf("failed to list dir: %w", err)
    86  				}
    87  				symlinkErrors = append(symlinkErrors, symErrors...)
    88  				all = append(all, files...)
    89  			}
    90  		}
    91  	} else {
    92  		// Directory
    93  		files, symErrors, err := listDir(inp, projectRoot)
    94  		if err != nil {
    95  			return nil, fmt.Errorf("failed to list dir: %w", err)
    96  		}
    97  		symlinkErrors = append(symlinkErrors, symErrors...)
    98  		all = append(all, files...)
    99  	}
   100  
   101  	for i, sErr := range symlinkErrors {
   102  		fmt.Println(fmt.Sprintf("%s", aurora.Red("Warning: ")) + sErr.Error())
   103  		if i > 10 {
   104  			break
   105  		}
   106  	}
   107  
   108  	// listRecursiveMap[inp] = all
   109  	return all, nil
   110  }
   111  
   112  func listDir(path string, projectRoot string) (all []string, symlinkErrors []error, _ error) {
   113  
   114  	symlinkErrors = []error{}
   115  	all = []string{}
   116  	if err := filepath.WalkDir(path, func(p string, fi fs.DirEntry, err error) error {
   117  		if err != nil {
   118  			return err
   119  		}
   120  
   121  		// Skip default ignored
   122  		if fi.IsDir() && ignored(fi.Name()) {
   123  			return fs.SkipDir
   124  		}
   125  
   126  		// Append file
   127  		if fi.IsDir() {
   128  			return nil
   129  		}
   130  
   131  		fileInfo, err := fi.Info()
   132  		if err != nil {
   133  			return err
   134  		}
   135  
   136  		isValid, err := isValidFile(p, fileInfo, projectRoot)
   137  		if err != nil {
   138  			symlinkErrors = append(symlinkErrors, err)
   139  		}
   140  
   141  		if isValid {
   142  			all = append(all, p)
   143  		}
   144  
   145  		return nil
   146  	}); err != nil {
   147  		return nil, nil, fmt.Errorf("failed to walk dir %q: %w", path, err)
   148  	}
   149  
   150  	return all, symlinkErrors, nil
   151  }
   152  
   153  // isValidFile returns true if a symlink resolves succesfully into a path relative to projectRoot.
   154  // It also returns true if the file is a regular file or directory.
   155  //
   156  // The returned error contains a "failed to follow symlink" hint and should
   157  // be presented to the user.
   158  func isValidFile(path string, info fs.FileInfo, projectRoot string) (bool, error) {
   159  	if info.Mode()&os.ModeSymlink != 0 {
   160  		sym, err := filepath.EvalSymlinks(path)
   161  		if err != nil {
   162  			return false, fmt.Errorf("failed to follow symlink %q: %w", path, err)
   163  		}
   164  
   165  		if strings.HasPrefix(sym, "/") {
   166  			return false, fmt.Errorf("symbolic link [%s] points to a location [%s] outside of the project [%s]", path, sym, projectRoot)
   167  		}
   168  
   169  		absSym, err := filepath.Abs(sym)
   170  		if err != nil {
   171  			return false, err
   172  		}
   173  		if !strings.HasPrefix(absSym, projectRoot) {
   174  			return false, fmt.Errorf("symbolic link [%s] points to a location [%s] outside of the project [%s]", path, sym, projectRoot)
   175  		}
   176  
   177  		// the symlink itself should not appear in the input list
   178  		// only the resolved path should be added.
   179  		return false, nil
   180  	}
   181  
   182  	return true, nil
   183  }
   184  
   185  func ignored(fileName string) bool {
   186  	return DefaultIgnores[fileName]
   187  }