[engine] Add ctx.io.tempfile method

This will be useful most notably for integrating with formatters that
don't have a dry-run mode. shac requires that formatter checks emit
the formatted result as a finding rather than writing the result at
runtime, so formatters that lack a dry run mode can now do:

  def foo_fmt(ctx):
    for path in ctx.scm.affected_files():
      original = ctx.io.read_file(path)
      temp = ctx.io.tempfile(ctx.io.read_file(path))
      ctx.os.exec(["foo", "fmt", temp])
      new = ctx.io.read_file(temp)
      if new != original:
	ctx.emit.finding(
	  filepath=path,
	  replacements=[ctx.io.read_file(temp)],
	)

Change-Id: Ic6438a16a263f4250cb0c276fa988f89ac028101
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/910595
Reviewed-by: Anthony Fandrianto <atyfto@google.com>
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
diff --git a/doc/stdlib.md b/doc/stdlib.md
index 3998b6b..d372cf6 100644
--- a/doc/stdlib.md
+++ b/doc/stdlib.md
@@ -157,6 +157,7 @@
 
 - read_file
 - tempdir
+- tempfile
 
 ## ctx.io.read_file
 
@@ -189,6 +190,19 @@
 Returns a new temporary directory.
 
 
+## ctx.io.tempfile
+
+Returns a new temporary file.
+
+### Arguments
+
+* **content**: String or bytes to populate the file with.
+* **name**: (optional) The basename to give the file. May contain path separators, in which case the file will be nested accordingly. Will be chosen randomly if not specified.
+
+### Returns
+
+Absolute path to the created file.
+
 ## ctx.os
 
 ctx.os is the object that exposes the API to interact with the operating
diff --git a/doc/stdlib.star b/doc/stdlib.star
index 60bec72..9733799 100644
--- a/doc/stdlib.star
+++ b/doc/stdlib.star
@@ -183,6 +183,21 @@
   pass
 
 
+def _ctx_io_tempfile(content, name):
+  """Returns a new temporary file.
+
+  Args:
+    content: String or bytes to populate the file with.
+    name: (optional) The basename to give the file. May contain path
+      separators, in which case the file will be nested accordingly. Will be
+      chosen randomly if not specified.
+
+  Returns:
+    Absolute path to the created file.
+  """
+  pass
+
+
 def _ctx_os_exec(
   cmd,
   cwd = None,
@@ -374,6 +389,7 @@
   io = struct(
     read_file = _ctx_io_read_file,
     tempdir = _ctx_io_tempdir,
+    tempfile = _ctx_io_tempfile,
   ),
   # ctx.os is the object that exposes the API to interact with the operating
   # system.
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 81d1ddb..91063ce 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -1082,11 +1082,6 @@
 			"  //ctx-immutable.star:17:6: in cb\n",
 		},
 		{
-			"ctx-io-read_file-abs.star",
-			"ctx.io.read_file: for parameter \"filepath\": \"/dev/null\" do not use absolute path",
-			"  //ctx-io-read_file-abs.star:16:19: in cb\n",
-		},
-		{
 			"ctx-io-read_file-dir.star",
 			"ctx.io.read_file: for parameter \"filepath\": \".\" is a directory",
 			"  //ctx-io-read_file-dir.star:16:19: in cb\n",
@@ -1673,6 +1668,11 @@
 			}(),
 		},
 		{
+			"ctx-io-tempfile.star",
+			"[//ctx-io-tempfile.star:18] first\nfile\ncontents\n\n" +
+				"[//ctx-io-tempfile.star:19] contents\nof\nsecond\nfile\n\n",
+		},
+		{
 			"ctx-os-exec-10Mib.star",
 			"[//ctx-os-exec-10Mib.star:17] 0\n",
 		},
diff --git a/internal/engine/runtime_ctx.go b/internal/engine/runtime_ctx.go
index 942b431..ae0b0a8 100644
--- a/internal/engine/runtime_ctx.go
+++ b/internal/engine/runtime_ctx.go
@@ -33,6 +33,7 @@
 		"io": toValue("ctx.io", starlark.StringDict{
 			"read_file": newBuiltin("ctx.io.read_file", ctxIoReadFile),
 			"tempdir":   newBuiltin("ctx.io.tempdir", ctxIoTempdir),
+			"tempfile":  newBuiltin("ctx.io.tempfile", ctxIoTempfile),
 		}),
 		"os": toValue("ctx.os", starlark.StringDict{
 			"exec": newBuiltin("ctx.os.exec", ctxOsExec),
diff --git a/internal/engine/runtime_ctx_io.go b/internal/engine/runtime_ctx_io.go
index 94001d5..3767c17 100644
--- a/internal/engine/runtime_ctx_io.go
+++ b/internal/engine/runtime_ctx_io.go
@@ -46,9 +46,13 @@
 	if !ok {
 		return nil, fmt.Errorf("for parameter \"size\": %s is an invalid size", argsize)
 	}
-	dst, err := absPath(string(argfilepath), filepath.Join(s.root, s.subdir))
-	if err != nil {
-		return nil, fmt.Errorf("for parameter \"filepath\": %s %w", argfilepath, err)
+	dst := string(argfilepath)
+	if !filepath.IsAbs(dst) {
+		var err error
+		dst, err = absPath(dst, filepath.Join(s.root, s.subdir))
+		if err != nil {
+			return nil, fmt.Errorf("for parameter \"filepath\": %s %w", argfilepath, err)
+		}
 	}
 	b, err := readFileImpl(dst, size)
 	if err != nil {
@@ -74,6 +78,48 @@
 	return starlark.String(t), err
 }
 
+// ctxIoTempfile implements native function ctx.io.tempfile().
+//
+// Make sure to update //doc/stdlib.star whenever this function is modified.
+func ctxIoTempfile(ctx context.Context, s *shacState, name string, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var argcontent starlark.Value
+	var argname starlark.String = "00001"
+	if err := starlark.UnpackArgs(name, args, kwargs,
+		"content", &argcontent,
+		"name?", &argname,
+	); err != nil {
+		return nil, err
+	}
+	var content []byte
+	switch v := argcontent.(type) {
+	case starlark.Bytes:
+		content = unsafeByteSlice(string(v))
+	case starlark.String:
+		content = unsafeByteSlice(string(v))
+	default:
+		return nil, fmt.Errorf("for parameter \"content\": got %s, want str or bytes", argcontent.Type())
+	}
+
+	// TODO(olivernewman): Consider not creating a new dir for every temp file.
+	dir, err := s.newTempDir()
+	if err != nil {
+		return nil, err
+	}
+	if filepath.IsAbs(string(argname)) {
+		return nil, fmt.Errorf("for parameter \"name\": absolute paths are not allowed")
+	}
+
+	path := filepath.Join(dir, string(argname))
+	if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return nil, err
+	}
+
+	if err = os.WriteFile(path, content, 0o600); err != nil {
+		return nil, err
+	}
+	return starlark.String(path), err
+}
+
 // Support functions.
 
 // readFileImpl is similar to os.ReadFile() albeit it limits the amount of data
diff --git a/internal/engine/runtime_shac.go b/internal/engine/runtime_shac.go
index 0704fe1..86756e7 100644
--- a/internal/engine/runtime_shac.go
+++ b/internal/engine/runtime_shac.go
@@ -27,7 +27,7 @@
 	// Version is the current tool version.
 	//
 	// TODO(maruel): Add proper version, preferably from git tag.
-	Version = [...]int{0, 1, 2}
+	Version = [...]int{0, 1, 3}
 )
 
 // getShac returns the global shac object.
diff --git a/internal/engine/testdata/fail_or_throw/ctx-io-read_file-abs.star b/internal/engine/testdata/print/ctx-io-tempfile.star
similarity index 74%
rename from internal/engine/testdata/fail_or_throw/ctx-io-read_file-abs.star
rename to internal/engine/testdata/print/ctx-io-tempfile.star
index 8797962..59fe1b2 100644
--- a/internal/engine/testdata/fail_or_throw/ctx-io-read_file-abs.star
+++ b/internal/engine/testdata/print/ctx-io-tempfile.star
@@ -13,6 +13,9 @@
 # limitations under the License.
 
 def cb(ctx):
-  ctx.io.read_file("/dev/null")
+  first = ctx.io.tempfile("first\nfile\ncontents\n")
+  second = ctx.io.tempfile(b"contents\nof\nsecond\nfile\n", name="dir/second.txt")
+  print(ctx.io.read_file(first))
+  print(ctx.io.read_file(second))
 
 shac.register_check(cb)