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 }