github.com/opentofu/opentofu@v1.7.1/internal/lang/funcs/filesystem.go (about)

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