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 }