github.com/terraform-linters/tflint@v0.51.2-0.20240520175844-3750771571b6/terraform/lang/funcs/filesystem.go (about)

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