[engine] Add `stdin` arg to `ctx.os.exec()`

For getting suggested replacements for formatters, it's useful for the
formatter to be able to write the formatted code to stdout. Some
formatters, such as Black, will only write formatted results to stdout
if the input is passed via stdin.

Change-Id: I738aa3d236e32404e96ee5cba3230a510a4d6951
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/887593
Reviewed-by: Marc-Antoine Ruel <maruel@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 089c602..46bc06f 100644
--- a/doc/stdlib.md
+++ b/doc/stdlib.md
@@ -247,6 +247,7 @@
 * **cmd**: Subprocess command line.
 * **cwd**: (optional) Relative path to cwd for the subprocess. Defaults to the directory containing shac.star.
 * **env**: (optional) Dictionary of environment variables to set for the subprocess.
+* **stdin**: (optional) str or bytes to pass to the subprocess as standard input.
 * **allow_network**: (optional) Allow network access. Defaults to false.
 * **ok_retcodes**: (optional) List of exit codes that should be considered successes. Any other exit code will immediately fail the check. The effective default is [0].
 * **raise_on_failure**: (optional) Whether the running check should automatically fail if the subcommand returns a non-zero exit code. Defaults to true. Cannot be false if ok_retcodes is also set.
diff --git a/doc/stdlib.star b/doc/stdlib.star
index e2d6706..d7d84f3 100644
--- a/doc/stdlib.star
+++ b/doc/stdlib.star
@@ -185,6 +185,7 @@
   cmd,
   cwd = None,
   env = None,
+  stdin = None,
   allow_network = False,
   ok_retcodes = None,
   raise_on_failure = True,
@@ -230,6 +231,7 @@
       directory containing shac.star.
     env: (optional) Dictionary of environment variables to set for the
       subprocess.
+    stdin: (optional) str or bytes to pass to the subprocess as standard input.
     allow_network: (optional) Allow network access. Defaults to false.
     ok_retcodes: (optional) List of exit codes that should be considered
       successes. Any other exit code will immediately fail the check. The
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 3e783bf..f1d9424 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -1034,6 +1034,11 @@
 			"  //ctx-os-exec-bad_env_value.star:16:14: in cb\n",
 		},
 		{
+			"ctx-os-exec-bad_stdin_type.star",
+			"ctx.os.exec: for parameter \"stdin\": got dict, want str or bytes",
+			"  //ctx-os-exec-bad_stdin_type.star:16:14: in cb\n",
+		},
+		{
 			"ctx-os-exec-bad_type_in_args.star",
 			"ctx.os.exec: for parameter \"cmd\": got list, want sequence of str",
 			"  //ctx-os-exec-bad_type_in_args.star:16:14: in cb\n",
@@ -1575,6 +1580,15 @@
 			strings.Repeat("[//ctx-os-exec-parallel.star:27] Hello, world\n", 10),
 		},
 		{
+			"ctx-os-exec-stdin.star",
+			"[//ctx-os-exec-stdin.star:30] stdout given NoneType for stdin:\n" +
+				"\n" +
+				"[//ctx-os-exec-stdin.star:30] stdout given string for stdin:\n" +
+				"hello\nfrom\nstdin\nstring\n" +
+				"[//ctx-os-exec-stdin.star:30] stdout given bytes for stdin:\n" +
+				"hello\nfrom\nstdin\nbytes\n",
+		},
+		{
 			"ctx-os-exec-success.star",
 			"[//ctx-os-exec-success.star:21] retcode: 0\n" +
 				"[//ctx-os-exec-success.star:22] stdout: hello from stdout\n" +
diff --git a/internal/engine/runtime_ctx_os.go b/internal/engine/runtime_ctx_os.go
index 71b11bc..6a24ae0 100644
--- a/internal/engine/runtime_ctx_os.go
+++ b/internal/engine/runtime_ctx_os.go
@@ -19,6 +19,7 @@
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"math"
 	"os"
 	"os/exec"
@@ -155,6 +156,7 @@
 	var argcmd starlark.Sequence
 	var argcwd starlark.String
 	var argenv = starlark.NewDict(0)
+	var argstdin starlark.Value = starlark.None
 	var argraiseOnFailure starlark.Bool = true
 	var argallowNetwork starlark.Bool
 	var argokRetcodes starlark.Value = starlark.None
@@ -162,6 +164,7 @@
 		"cmd", &argcmd,
 		"cwd?", &argcwd,
 		"env?", &argenv,
+		"stdin?", &argstdin,
 		"allow_network?", &argallowNetwork,
 		"ok_retcodes?", &argokRetcodes,
 		"raise_on_failure?", &argraiseOnFailure,
@@ -251,6 +254,17 @@
 		env[string(k)] = string(v)
 	}
 
+	var stdin io.Reader
+	switch s := argstdin.(type) {
+	case starlark.String:
+		stdin = strings.NewReader(string(s))
+	case starlark.Bytes:
+		stdin = bytes.NewReader([]byte(s))
+	case starlark.NoneType:
+	default:
+		return nil, fmt.Errorf("for parameter \"stdin\": got %s, want str or bytes", argstdin.Type())
+	}
+
 	cwd := filepath.Join(s.root, s.subdir)
 	if s := string(argcwd); s != "" {
 		cwd, err = absPath(s, cwd)
@@ -334,6 +348,7 @@
 
 	cmd := s.sandbox.Command(ctx, config)
 
+	cmd.Stdin = stdin
 	// TODO(olivernewman): Also handle commands that may output non-utf-8 bytes.
 	cmd.Stdout = stdout
 	cmd.Stderr = stderr
diff --git a/internal/engine/testdata/fail_or_throw/ctx-os-exec-bad_stdin_type.star b/internal/engine/testdata/fail_or_throw/ctx-os-exec-bad_stdin_type.star
new file mode 100644
index 0000000..6d82161
--- /dev/null
+++ b/internal/engine/testdata/fail_or_throw/ctx-os-exec-bad_stdin_type.star
@@ -0,0 +1,18 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+def cb(ctx):
+  ctx.os.exec(["true"], stdin = {"not a": "valid stdin value"}).wait()
+
+shac.register_check(cb)
diff --git a/internal/engine/testdata/print/ctx-os-exec-stdin.go b/internal/engine/testdata/print/ctx-os-exec-stdin.go
new file mode 100644
index 0000000..a35df28
--- /dev/null
+++ b/internal/engine/testdata/print/ctx-os-exec-stdin.go
@@ -0,0 +1,32 @@
+// Copyright 2023 The Shac Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build ignore
+
+package main
+
+import (
+	"fmt"
+	"io"
+	"os"
+)
+
+func main() {
+	b, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		fmt.Fprint(os.Stderr, err.Error())
+		os.Exit(1)
+	}
+	fmt.Fprintf(os.Stdout, string(b))
+}
diff --git a/internal/engine/testdata/print/ctx-os-exec-stdin.star b/internal/engine/testdata/print/ctx-os-exec-stdin.star
new file mode 100644
index 0000000..255d0a9
--- /dev/null
+++ b/internal/engine/testdata/print/ctx-os-exec-stdin.star
@@ -0,0 +1,39 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+def cb(ctx):
+  cmd = ["go", "run", "ctx-os-exec-stdin.go"]
+  test_inputs = [
+    None,
+    "hello\nfrom\nstdin\nstring",
+    b"hello\nfrom\nstdin\nbytes",
+  ]
+
+  procs = []
+  for stdin in test_inputs:
+    procs.append(ctx.os.exec(cmd, env = _go_env(ctx), stdin = stdin))
+
+  for i, proc in enumerate(procs):
+    res = proc.wait()
+    stdin = test_inputs[i]
+    print("stdout given %s for stdin:\n%s" % (type(stdin), res.stdout))
+
+def _go_env(ctx):
+  return {
+    "CGO_ENABLED": "0",
+    "GOCACHE": ctx.io.tempdir() + "/gocache",
+    "GOPACKAGESDRIVER": "off",
+  }
+
+shac.register_check(cb)