github.com/kubeshop/testkube@v1.17.23/pkg/tcl/expressionstcl/libs/fs.go (about)

     1  // Copyright 2024 Testkube.
     2  //
     3  // Licensed as a Testkube Pro file under the Testkube Community
     4  // License (the "License"); you may not use this file except in compliance with
     5  // the License. You may obtain a copy of the License at
     6  //
     7  //     https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt
     8  
     9  package libs
    10  
    11  import (
    12  	"errors"
    13  	"fmt"
    14  	"io"
    15  	"io/fs"
    16  	"path/filepath"
    17  	"strings"
    18  
    19  	"github.com/bmatcuk/doublestar/v4"
    20  
    21  	"github.com/kubeshop/testkube/pkg/tcl/expressionstcl"
    22  )
    23  
    24  func absPath(p, workingDir string) string {
    25  	if !filepath.IsAbs(p) {
    26  		p = filepath.Join(workingDir, p)
    27  	}
    28  	v, err := filepath.Abs(p)
    29  	if err != nil {
    30  		v = p
    31  	}
    32  	v = strings.TrimRight(filepath.ToSlash(v), "/")
    33  	if v == "" {
    34  		return "/"
    35  	}
    36  	return v
    37  }
    38  
    39  func findSearchRoot(pattern, workingDir string) string {
    40  	path, _ := doublestar.SplitPattern(pattern + "/")
    41  	path = strings.TrimRight(path, "/")
    42  	if path == "." {
    43  		return strings.TrimLeft(absPath("", workingDir), "/")
    44  	}
    45  	return strings.TrimLeft(path, "/")
    46  }
    47  
    48  func mapSlice[T any, U any](s []T, fn func(T) U) []U {
    49  	result := make([]U, len(s))
    50  	for i := range s {
    51  		result[i] = fn(s[i])
    52  	}
    53  	return result
    54  }
    55  
    56  func deduplicateRoots(paths []string) []string {
    57  	result := make([]string, 0)
    58  	unique := make(map[string]struct{})
    59  	for _, p := range paths {
    60  		unique[p] = struct{}{}
    61  	}
    62  loop:
    63  	for path := range unique {
    64  		for path2 := range unique {
    65  			if strings.HasPrefix(path, path2+"/") {
    66  				continue loop
    67  			}
    68  		}
    69  		result = append(result, path)
    70  	}
    71  	return result
    72  }
    73  
    74  func readFile(fsys fs.FS, workingDir string, values ...expressionstcl.StaticValue) (interface{}, error) {
    75  	if len(values) != 1 {
    76  		return nil, errors.New("file() function takes a single argument")
    77  	}
    78  	if !values[0].IsString() {
    79  		return nil, fmt.Errorf("file() function expects a string argument, provided: %v", values[0].String())
    80  	}
    81  	filePath, _ := values[0].StringValue()
    82  	file, err := fsys.Open(strings.TrimLeft(absPath(filePath, workingDir), "/"))
    83  	if err != nil {
    84  		return nil, fmt.Errorf("opening file(%s): %s", filePath, err.Error())
    85  	}
    86  	content, err := io.ReadAll(file)
    87  	if err != nil {
    88  		return nil, fmt.Errorf("reading file(%s): %s", filePath, err.Error())
    89  	}
    90  	return string(content), nil
    91  }
    92  
    93  func createGlobMatcher(patterns []string) func(string) bool {
    94  	return func(filePath string) bool {
    95  		for _, p := range patterns {
    96  			v, _ := doublestar.PathMatch(p, filePath)
    97  			if v {
    98  				return true
    99  			}
   100  		}
   101  		return false
   102  	}
   103  }
   104  
   105  func globFs(fsys fs.FS, workingDir string, values ...expressionstcl.StaticValue) (interface{}, error) {
   106  	if len(values) == 0 {
   107  		return nil, errors.New("glob() function takes at least one argument")
   108  	}
   109  
   110  	// Read all the patterns
   111  	ignorePatterns := make([]string, 0)
   112  	patterns := make([]string, 0)
   113  	for i := 0; i < len(values); i++ {
   114  		v, _ := values[i].StringValue()
   115  		if strings.HasPrefix(v, "!") {
   116  			ignorePatterns = append(ignorePatterns, absPath(v[1:], workingDir))
   117  		} else {
   118  			patterns = append(patterns, absPath(v, workingDir))
   119  		}
   120  	}
   121  	if len(patterns) == 0 {
   122  		return nil, errors.New("glob() function needs at least one matching pattern")
   123  	}
   124  	matchesPositive := createGlobMatcher(patterns)
   125  	matchesIgnore := createGlobMatcher(ignorePatterns)
   126  
   127  	// Determine roots for searching, to avoid scanning whole FS
   128  	findRoot := func(pattern string) string {
   129  		return findSearchRoot(pattern, workingDir)
   130  	}
   131  	roots := deduplicateRoots(mapSlice(patterns, findRoot))
   132  	result := make([]string, 0)
   133  	for _, root := range roots {
   134  		err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
   135  			if path == "." || err != nil || d.IsDir() {
   136  				return nil
   137  			}
   138  			path = "/" + path
   139  			if !matchesPositive(path) || matchesIgnore(path) {
   140  				return nil
   141  			}
   142  			result = append(result, path)
   143  			return nil
   144  		})
   145  		if err != nil {
   146  			return nil, fmt.Errorf("glob() error: %v", err)
   147  		}
   148  	}
   149  
   150  	return result, nil
   151  }
   152  
   153  func NewFsMachine(fsys fs.FS, workingDir string) expressionstcl.Machine {
   154  	if workingDir == "" {
   155  		workingDir = "/"
   156  	}
   157  	return expressionstcl.NewMachine().
   158  		RegisterFunction("file", func(values ...expressionstcl.StaticValue) (interface{}, bool, error) {
   159  			v, err := readFile(fsys, workingDir, values...)
   160  			return v, true, err
   161  		}).
   162  		RegisterFunction("glob", func(values ...expressionstcl.StaticValue) (interface{}, bool, error) {
   163  			v, err := globFs(fsys, workingDir, values...)
   164  			return v, true, err
   165  		})
   166  }