github.com/eliastor/durgaform@v0.0.0-20220816172711-d0ab2d17673e/internal/lang/funcs/filesystem.go (about)

     1  package funcs
     2  
     3  import (
     4  	"encoding/base64"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"os"
     8  	"path/filepath"
     9  	"unicode/utf8"
    10  
    11  	"github.com/bmatcuk/doublestar"
    12  	"github.com/hashicorp/hcl/v2"
    13  	"github.com/hashicorp/hcl/v2/hclsyntax"
    14  	homedir "github.com/mitchellh/go-homedir"
    15  	"github.com/zclconf/go-cty/cty"
    16  	"github.com/zclconf/go-cty/cty/function"
    17  )
    18  
    19  // MakeFileFunc constructs a function that takes a file path and returns the
    20  // contents of that file, either directly as a string (where valid UTF-8 is
    21  // required) or as a string containing base64 bytes.
    22  func MakeFileFunc(baseDir string, encBase64 bool) function.Function {
    23  	return function.New(&function.Spec{
    24  		Params: []function.Parameter{
    25  			{
    26  				Name:        "path",
    27  				Type:        cty.String,
    28  				AllowMarked: true,
    29  			},
    30  		},
    31  		Type: function.StaticReturnType(cty.String),
    32  		Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
    33  			pathArg, pathMarks := args[0].Unmark()
    34  			path := pathArg.AsString()
    35  			src, err := readFileBytes(baseDir, path, pathMarks)
    36  			if err != nil {
    37  				err = function.NewArgError(0, err)
    38  				return cty.UnknownVal(cty.String), err
    39  			}
    40  
    41  			switch {
    42  			case encBase64:
    43  				enc := base64.StdEncoding.EncodeToString(src)
    44  				return cty.StringVal(enc).WithMarks(pathMarks), nil
    45  			default:
    46  				if !utf8.Valid(src) {
    47  					return cty.UnknownVal(cty.String), fmt.Errorf("contents of %s are not valid UTF-8; use the filebase64 function to obtain the Base64 encoded contents or the other file functions (e.g. filemd5, filesha256) to obtain file hashing results instead", redactIfSensitive(path, pathMarks))
    48  				}
    49  				return cty.StringVal(string(src)).WithMarks(pathMarks), nil
    50  			}
    51  		},
    52  	})
    53  }
    54  
    55  // MakeTemplateFileFunc constructs a function that takes a file path and
    56  // an arbitrary object of named values and attempts to render the referenced
    57  // file as a template using HCL template syntax.
    58  //
    59  // The template itself may recursively call other functions so a callback
    60  // must be provided to get access to those functions. The template cannot,
    61  // however, access any variables defined in the scope: it is restricted only to
    62  // those variables provided in the second function argument, to ensure that all
    63  // dependencies on other graph nodes can be seen before executing this function.
    64  //
    65  // As a special exception, a referenced template file may not recursively call
    66  // the templatefile function, since that would risk the same file being
    67  // included into itself indefinitely.
    68  func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Function) function.Function {
    69  
    70  	params := []function.Parameter{
    71  		{
    72  			Name:        "path",
    73  			Type:        cty.String,
    74  			AllowMarked: true,
    75  		},
    76  		{
    77  			Name: "vars",
    78  			Type: cty.DynamicPseudoType,
    79  		},
    80  	}
    81  
    82  	loadTmpl := func(fn string, marks cty.ValueMarks) (hcl.Expression, error) {
    83  		// We re-use File here to ensure the same filename interpretation
    84  		// as it does, along with its other safety checks.
    85  		tmplVal, err := File(baseDir, cty.StringVal(fn).WithMarks(marks))
    86  		if err != nil {
    87  			return nil, err
    88  		}
    89  
    90  		expr, diags := hclsyntax.ParseTemplate([]byte(tmplVal.AsString()), fn, hcl.Pos{Line: 1, Column: 1})
    91  		if diags.HasErrors() {
    92  			return nil, diags
    93  		}
    94  
    95  		return expr, nil
    96  	}
    97  
    98  	renderTmpl := func(expr hcl.Expression, varsVal cty.Value) (cty.Value, error) {
    99  		if varsTy := varsVal.Type(); !(varsTy.IsMapType() || varsTy.IsObjectType()) {
   100  			return cty.DynamicVal, function.NewArgErrorf(1, "invalid vars value: must be a map") // or an object, but we don't strongly distinguish these most of the time
   101  		}
   102  
   103  		ctx := &hcl.EvalContext{
   104  			Variables: varsVal.AsValueMap(),
   105  		}
   106  
   107  		// We require all of the variables to be valid HCL identifiers, because
   108  		// otherwise there would be no way to refer to them in the template
   109  		// anyway. Rejecting this here gives better feedback to the user
   110  		// than a syntax error somewhere in the template itself.
   111  		for n := range ctx.Variables {
   112  			if !hclsyntax.ValidIdentifier(n) {
   113  				// This error message intentionally doesn't describe _all_ of
   114  				// the different permutations that are technically valid as an
   115  				// HCL identifier, but rather focuses on what we might
   116  				// consider to be an "idiomatic" variable name.
   117  				return cty.DynamicVal, function.NewArgErrorf(1, "invalid template variable name %q: must start with a letter, followed by zero or more letters, digits, and underscores", n)
   118  			}
   119  		}
   120  
   121  		// We'll pre-check references in the template here so we can give a
   122  		// more specialized error message than HCL would by default, so it's
   123  		// clearer that this problem is coming from a templatefile call.
   124  		for _, traversal := range expr.Variables() {
   125  			root := traversal.RootName()
   126  			if _, ok := ctx.Variables[root]; !ok {
   127  				return cty.DynamicVal, function.NewArgErrorf(1, "vars map does not contain key %q, referenced at %s", root, traversal[0].SourceRange())
   128  			}
   129  		}
   130  
   131  		givenFuncs := funcsCb() // this callback indirection is to avoid chicken/egg problems
   132  		funcs := make(map[string]function.Function, len(givenFuncs))
   133  		for name, fn := range givenFuncs {
   134  			if name == "templatefile" {
   135  				// We stub this one out to prevent recursive calls.
   136  				funcs[name] = function.New(&function.Spec{
   137  					Params: params,
   138  					Type: func(args []cty.Value) (cty.Type, error) {
   139  						return cty.NilType, fmt.Errorf("cannot recursively call templatefile from inside templatefile call")
   140  					},
   141  				})
   142  				continue
   143  			}
   144  			funcs[name] = fn
   145  		}
   146  		ctx.Functions = funcs
   147  
   148  		val, diags := expr.Value(ctx)
   149  		if diags.HasErrors() {
   150  			return cty.DynamicVal, diags
   151  		}
   152  		return val, nil
   153  	}
   154  
   155  	return function.New(&function.Spec{
   156  		Params: params,
   157  		Type: func(args []cty.Value) (cty.Type, error) {
   158  			if !(args[0].IsKnown() && args[1].IsKnown()) {
   159  				return cty.DynamicPseudoType, nil
   160  			}
   161  
   162  			// We'll render our template now to see what result type it produces.
   163  			// A template consisting only of a single interpolation an potentially
   164  			// return any type.
   165  
   166  			pathArg, pathMarks := args[0].Unmark()
   167  			expr, err := loadTmpl(pathArg.AsString(), pathMarks)
   168  			if err != nil {
   169  				return cty.DynamicPseudoType, err
   170  			}
   171  
   172  			// This is safe even if args[1] contains unknowns because the HCL
   173  			// template renderer itself knows how to short-circuit those.
   174  			val, err := renderTmpl(expr, args[1])
   175  			return val.Type(), err
   176  		},
   177  		Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
   178  			pathArg, pathMarks := args[0].Unmark()
   179  			expr, err := loadTmpl(pathArg.AsString(), pathMarks)
   180  			if err != nil {
   181  				return cty.DynamicVal, err
   182  			}
   183  			result, err := renderTmpl(expr, args[1])
   184  			return result.WithMarks(pathMarks), err
   185  		},
   186  	})
   187  
   188  }
   189  
   190  // MakeFileExistsFunc constructs a function that takes a path
   191  // and determines whether a file exists at that path
   192  func MakeFileExistsFunc(baseDir string) function.Function {
   193  	return function.New(&function.Spec{
   194  		Params: []function.Parameter{
   195  			{
   196  				Name:        "path",
   197  				Type:        cty.String,
   198  				AllowMarked: true,
   199  			},
   200  		},
   201  		Type: function.StaticReturnType(cty.Bool),
   202  		Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
   203  			pathArg, pathMarks := args[0].Unmark()
   204  			path := pathArg.AsString()
   205  			path, err := homedir.Expand(path)
   206  			if err != nil {
   207  				return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to expand ~: %w", err)
   208  			}
   209  
   210  			if !filepath.IsAbs(path) {
   211  				path = filepath.Join(baseDir, path)
   212  			}
   213  
   214  			// Ensure that the path is canonical for the host OS
   215  			path = filepath.Clean(path)
   216  
   217  			fi, err := os.Stat(path)
   218  			if err != nil {
   219  				if os.IsNotExist(err) {
   220  					return cty.False.WithMarks(pathMarks), nil
   221  				}
   222  				return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to stat %s", redactIfSensitive(path, pathMarks))
   223  			}
   224  
   225  			if fi.Mode().IsRegular() {
   226  				return cty.True.WithMarks(pathMarks), nil
   227  			}
   228  
   229  			// The Go stat API only provides convenient access to whether it's
   230  			// a directory or not, so we need to do some bit fiddling to
   231  			// recognize other irregular file types.
   232  			filename := redactIfSensitive(path, pathMarks)
   233  			fileType := fi.Mode().Type()
   234  			switch {
   235  			case (fileType & os.ModeDir) != 0:
   236  				err = function.NewArgErrorf(1, "%s is a directory, not a file", filename)
   237  			case (fileType & os.ModeDevice) != 0:
   238  				err = function.NewArgErrorf(1, "%s is a device node, not a regular file", filename)
   239  			case (fileType & os.ModeNamedPipe) != 0:
   240  				err = function.NewArgErrorf(1, "%s is a named pipe, not a regular file", filename)
   241  			case (fileType & os.ModeSocket) != 0:
   242  				err = function.NewArgErrorf(1, "%s is a unix domain socket, not a regular file", filename)
   243  			default:
   244  				// If it's not a type we recognize then we'll just return a
   245  				// generic error message. This should be very rare.
   246  				err = function.NewArgErrorf(1, "%s is not a regular file", filename)
   247  
   248  				// Note: os.ModeSymlink should be impossible because we used
   249  				// os.Stat above, not os.Lstat.
   250  			}
   251  
   252  			return cty.False, err
   253  		},
   254  	})
   255  }
   256  
   257  // MakeFileSetFunc constructs a function that takes a glob pattern
   258  // and enumerates a file set from that pattern
   259  func MakeFileSetFunc(baseDir string) function.Function {
   260  	return function.New(&function.Spec{
   261  		Params: []function.Parameter{
   262  			{
   263  				Name:        "path",
   264  				Type:        cty.String,
   265  				AllowMarked: true,
   266  			},
   267  			{
   268  				Name:        "pattern",
   269  				Type:        cty.String,
   270  				AllowMarked: true,
   271  			},
   272  		},
   273  		Type: function.StaticReturnType(cty.Set(cty.String)),
   274  		Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
   275  			pathArg, pathMarks := args[0].Unmark()
   276  			path := pathArg.AsString()
   277  			patternArg, patternMarks := args[1].Unmark()
   278  			pattern := patternArg.AsString()
   279  
   280  			marks := []cty.ValueMarks{pathMarks, patternMarks}
   281  
   282  			if !filepath.IsAbs(path) {
   283  				path = filepath.Join(baseDir, path)
   284  			}
   285  
   286  			// Join the path to the glob pattern, while ensuring the full
   287  			// pattern is canonical for the host OS. The joined path is
   288  			// automatically cleaned during this operation.
   289  			pattern = filepath.Join(path, pattern)
   290  
   291  			matches, err := doublestar.Glob(pattern)
   292  			if err != nil {
   293  				return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to glob pattern %s: %w", redactIfSensitive(pattern, marks...), err)
   294  			}
   295  
   296  			var matchVals []cty.Value
   297  			for _, match := range matches {
   298  				fi, err := os.Stat(match)
   299  
   300  				if err != nil {
   301  					return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to stat %s: %w", redactIfSensitive(match, marks...), err)
   302  				}
   303  
   304  				if !fi.Mode().IsRegular() {
   305  					continue
   306  				}
   307  
   308  				// Remove the path and file separator from matches.
   309  				match, err = filepath.Rel(path, match)
   310  
   311  				if err != nil {
   312  					return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to trim path of match %s: %w", redactIfSensitive(match, marks...), err)
   313  				}
   314  
   315  				// Replace any remaining file separators with forward slash (/)
   316  				// separators for cross-system compatibility.
   317  				match = filepath.ToSlash(match)
   318  
   319  				matchVals = append(matchVals, cty.StringVal(match))
   320  			}
   321  
   322  			if len(matchVals) == 0 {
   323  				return cty.SetValEmpty(cty.String).WithMarks(marks...), nil
   324  			}
   325  
   326  			return cty.SetVal(matchVals).WithMarks(marks...), nil
   327  		},
   328  	})
   329  }
   330  
   331  // BasenameFunc constructs a function that takes a string containing a filesystem path
   332  // and removes all except the last portion from it.
   333  var BasenameFunc = function.New(&function.Spec{
   334  	Params: []function.Parameter{
   335  		{
   336  			Name: "path",
   337  			Type: cty.String,
   338  		},
   339  	},
   340  	Type: function.StaticReturnType(cty.String),
   341  	Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
   342  		return cty.StringVal(filepath.Base(args[0].AsString())), nil
   343  	},
   344  })
   345  
   346  // DirnameFunc constructs a function that takes a string containing a filesystem path
   347  // and removes the last portion from it.
   348  var DirnameFunc = function.New(&function.Spec{
   349  	Params: []function.Parameter{
   350  		{
   351  			Name: "path",
   352  			Type: cty.String,
   353  		},
   354  	},
   355  	Type: function.StaticReturnType(cty.String),
   356  	Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
   357  		return cty.StringVal(filepath.Dir(args[0].AsString())), nil
   358  	},
   359  })
   360  
   361  // AbsPathFunc constructs a function that converts a filesystem path to an absolute path
   362  var AbsPathFunc = function.New(&function.Spec{
   363  	Params: []function.Parameter{
   364  		{
   365  			Name: "path",
   366  			Type: cty.String,
   367  		},
   368  	},
   369  	Type: function.StaticReturnType(cty.String),
   370  	Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
   371  		absPath, err := filepath.Abs(args[0].AsString())
   372  		return cty.StringVal(filepath.ToSlash(absPath)), err
   373  	},
   374  })
   375  
   376  // PathExpandFunc constructs a function that expands a leading ~ character to the current user's home directory.
   377  var PathExpandFunc = function.New(&function.Spec{
   378  	Params: []function.Parameter{
   379  		{
   380  			Name: "path",
   381  			Type: cty.String,
   382  		},
   383  	},
   384  	Type: function.StaticReturnType(cty.String),
   385  	Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
   386  
   387  		homePath, err := homedir.Expand(args[0].AsString())
   388  		return cty.StringVal(homePath), err
   389  	},
   390  })
   391  
   392  func openFile(baseDir, path string) (*os.File, error) {
   393  	path, err := homedir.Expand(path)
   394  	if err != nil {
   395  		return nil, fmt.Errorf("failed to expand ~: %w", err)
   396  	}
   397  
   398  	if !filepath.IsAbs(path) {
   399  		path = filepath.Join(baseDir, path)
   400  	}
   401  
   402  	// Ensure that the path is canonical for the host OS
   403  	path = filepath.Clean(path)
   404  
   405  	return os.Open(path)
   406  }
   407  
   408  func readFileBytes(baseDir, path string, marks cty.ValueMarks) ([]byte, error) {
   409  	f, err := openFile(baseDir, path)
   410  	if err != nil {
   411  		if os.IsNotExist(err) {
   412  			// An extra Durgaform-specific hint for this situation
   413  			return nil, fmt.Errorf("no file exists at %s; this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource", redactIfSensitive(path, marks))
   414  		}
   415  		return nil, err
   416  	}
   417  	defer f.Close()
   418  
   419  	src, err := ioutil.ReadAll(f)
   420  	if err != nil {
   421  		return nil, fmt.Errorf("failed to read file: %w", err)
   422  	}
   423  
   424  	return src, nil
   425  }
   426  
   427  // File reads the contents of the file at the given path.
   428  //
   429  // The file must contain valid UTF-8 bytes, or this function will return an error.
   430  //
   431  // The underlying function implementation works relative to a particular base
   432  // directory, so this wrapper takes a base directory string and uses it to
   433  // construct the underlying function before calling it.
   434  func File(baseDir string, path cty.Value) (cty.Value, error) {
   435  	fn := MakeFileFunc(baseDir, false)
   436  	return fn.Call([]cty.Value{path})
   437  }
   438  
   439  // FileExists determines whether a file exists at the given path.
   440  //
   441  // The underlying function implementation works relative to a particular base
   442  // directory, so this wrapper takes a base directory string and uses it to
   443  // construct the underlying function before calling it.
   444  func FileExists(baseDir string, path cty.Value) (cty.Value, error) {
   445  	fn := MakeFileExistsFunc(baseDir)
   446  	return fn.Call([]cty.Value{path})
   447  }
   448  
   449  // FileSet enumerates a set of files given a glob pattern
   450  //
   451  // The underlying function implementation works relative to a particular base
   452  // directory, so this wrapper takes a base directory string and uses it to
   453  // construct the underlying function before calling it.
   454  func FileSet(baseDir string, path, pattern cty.Value) (cty.Value, error) {
   455  	fn := MakeFileSetFunc(baseDir)
   456  	return fn.Call([]cty.Value{path, pattern})
   457  }
   458  
   459  // FileBase64 reads the contents of the file at the given path.
   460  //
   461  // The bytes from the file are encoded as base64 before returning.
   462  //
   463  // The underlying function implementation works relative to a particular base
   464  // directory, so this wrapper takes a base directory string and uses it to
   465  // construct the underlying function before calling it.
   466  func FileBase64(baseDir string, path cty.Value) (cty.Value, error) {
   467  	fn := MakeFileFunc(baseDir, true)
   468  	return fn.Call([]cty.Value{path})
   469  }
   470  
   471  // Basename takes a string containing a filesystem path and removes all except the last portion from it.
   472  //
   473  // The underlying function implementation works only with the path string and does not access the filesystem itself.
   474  // It is therefore unable to take into account filesystem features such as symlinks.
   475  //
   476  // If the path is empty then the result is ".", representing the current working directory.
   477  func Basename(path cty.Value) (cty.Value, error) {
   478  	return BasenameFunc.Call([]cty.Value{path})
   479  }
   480  
   481  // Dirname takes a string containing a filesystem path and removes the last portion from it.
   482  //
   483  // The underlying function implementation works only with the path string and does not access the filesystem itself.
   484  // It is therefore unable to take into account filesystem features such as symlinks.
   485  //
   486  // If the path is empty then the result is ".", representing the current working directory.
   487  func Dirname(path cty.Value) (cty.Value, error) {
   488  	return DirnameFunc.Call([]cty.Value{path})
   489  }
   490  
   491  // Pathexpand takes a string that might begin with a `~` segment, and if so it replaces that segment with
   492  // the current user's home directory path.
   493  //
   494  // The underlying function implementation works only with the path string and does not access the filesystem itself.
   495  // It is therefore unable to take into account filesystem features such as symlinks.
   496  //
   497  // If the leading segment in the path is not `~` then the given path is returned unmodified.
   498  func Pathexpand(path cty.Value) (cty.Value, error) {
   499  	return PathExpandFunc.Call([]cty.Value{path})
   500  }