[engine] Disallow calling register_check during load()

See rationale in runtime_shac.go.

If a valid reason for calling register_check from loaded files arises we
can reconsider, but it's easier if we're strict from the beginning.

Change-Id: Ic344d0b596b9b5f2d1be9c38e91d68cb339daf61
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/909933
Commit-Queue: Oliver Newman <olivernewman@google.com>
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
diff --git a/doc/stdlib.md b/doc/stdlib.md
index 3998b6b..fcb2966 100644
--- a/doc/stdlib.md
+++ b/doc/stdlib.md
@@ -748,6 +748,11 @@
 shac.register_check(cb, fail_often)
 ```
 
+register_check cannot be called while a file is being imported by `load()`.
+It can only be called from the top-level execution path of the main
+shac.star file, either directly or by importing a function that calls
+register_check from another file.
+
 ### Arguments
 
 * **check**: `shac.check()` object or Starlark function that is called back to implement the check.
diff --git a/doc/stdlib.star b/doc/stdlib.star
index 60bec72..77049c2 100644
--- a/doc/stdlib.star
+++ b/doc/stdlib.star
@@ -743,6 +743,11 @@
     shac.register_check(cb, fail_often)
     ```
 
+    register_check cannot be called while a file is being imported by `load()`.
+    It can only be called from the top-level execution path of the main
+    shac.star file, either directly or by importing a function that calls
+    register_check from another file.
+
   Args:
     check: `shac.check()` object or Starlark function that is called back to
       implement the check.
diff --git a/internal/engine/env.go b/internal/engine/env.go
index dec8b08..1c00271 100644
--- a/internal/engine/env.go
+++ b/internal/engine/env.go
@@ -108,9 +108,12 @@
 // files in parallel.
 type starlarkEnv struct {
 	// Immutable.
-	// globals is available to all load() statements. They must be frozen via
-	// Freeze().
-	globals starlark.StringDict
+	// execedFileGlobals is available to the top-level Starlark file. They must be frozen
+	// via Freeze().
+	execedFileGlobals starlark.StringDict
+	// loadedFileGlobals is available to any loaded Starlark files. They must be
+	// frozen via Freeze().
+	loadedFileGlobals starlark.StringDict
 	// packages are all the available packages. It must include __main__.
 	packages map[string]fs.FS
 
@@ -145,14 +148,14 @@
 		if err != nil {
 			return nil, err
 		}
-		return s.loadInner(ctx, th, skn, pi)
+		return s.loadInner(ctx, th, skn, pi, false)
 	}
 	t.SetLocal("shac.top", sk)
 	t.SetLocal("shac.pkg", sk)
-	return s.loadInner(ctx, t, sk, pi)
+	return s.loadInner(ctx, t, sk, pi, true)
 }
 
-func (s *starlarkEnv) loadInner(ctx context.Context, th *starlark.Thread, sk sourceKey, pi printImpl) (starlark.StringDict, error) {
+func (s *starlarkEnv) loadInner(ctx context.Context, th *starlark.Thread, sk sourceKey, pi printImpl, execed bool) (starlark.StringDict, error) {
 	key := sk.String()
 	s.mu.Lock()
 	ss, ok := s.sources[key]
@@ -194,7 +197,11 @@
 				oldsk := th.Local("shac.pkg").(sourceKey)
 				th.SetLocal("shac.pkg", sk)
 				fp := syntax.FilePortion{Content: d, FirstLine: 1, FirstCol: 1}
-				ss.globals, ss.err = starlark.ExecFile(th, sk.String(), fp, s.globals)
+				globals := s.execedFileGlobals
+				if !execed {
+					globals = s.loadedFileGlobals
+				}
+				ss.globals, ss.err = starlark.ExecFile(th, sk.String(), fp, globals)
 				th.SetLocal("shac.pkg", oldsk)
 				var errl resolve.ErrorList
 				if errors.As(ss.err, &errl) {
diff --git a/internal/engine/run.go b/internal/engine/run.go
index 66a71ff..e40a144 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -234,9 +234,10 @@
 		return err
 	}
 	env := starlarkEnv{
-		globals:  getPredeclared(),
-		sources:  map[string]*loadedSource{},
-		packages: packages,
+		execedFileGlobals: getPredeclared(true),
+		loadedFileGlobals: getPredeclared(false),
+		sources:           map[string]*loadedSource{},
+		packages:          packages,
 	}
 
 	newState := func(scm scmCheckout, subdir string, idx int) *shacState {
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 7c37ef0..c679dbe 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -1284,6 +1284,11 @@
 			"  //load-recurse.star:15:1: in <toplevel>\n",
 		},
 		{
+			"load-register_check.star",
+			"cannot load ./fail-check.star: shac.register_check: cannot be called from a loaded file",
+			"  //load-register_check.star:16:1: in <toplevel>\n",
+		},
+		{
 			"shac-immutable.star",
 			"can't assign to .key field of struct",
 			"  //shac-immutable.star:16:5: in <toplevel>\n",
diff --git a/internal/engine/runtime.go b/internal/engine/runtime.go
index 041ba62..46d43c4 100644
--- a/internal/engine/runtime.go
+++ b/internal/engine/runtime.go
@@ -31,9 +31,9 @@
 // The upstream starlark interpreter includes all the symbols described at
 // https://github.com/google/starlark-go/blob/HEAD/doc/spec.md#built-in-constants-and-functions
 // See https://pkg.go.dev/go.starlark.net/starlark#Universe for the default list.
-func getPredeclared() starlark.StringDict {
+func getPredeclared(execed bool) starlark.StringDict {
 	return starlark.StringDict{
-		"shac": toValue("shac", getShac()),
+		"shac": toValue("shac", getShac(execed)),
 
 		// Add https://bazel.build/rules/lib/json so it feels more natural to bazel
 		// users.
diff --git a/internal/engine/runtime_shac.go b/internal/engine/runtime_shac.go
index a4b0b61..a3e64b1 100644
--- a/internal/engine/runtime_shac.go
+++ b/internal/engine/runtime_shac.go
@@ -33,8 +33,8 @@
 // getShac returns the global shac object.
 //
 // Make sure to update //doc/stdlib.star whenever this function is modified.
-func getShac() starlark.StringDict {
-	return starlark.StringDict{
+func getShac(execed bool) starlark.StringDict {
+	ret := starlark.StringDict{
 		"check":          newBuiltin("shac.check", shacCheck),
 		"commit_hash":    starlark.String(getCommitHash()),
 		"register_check": newBuiltinNone("shac.register_check", shacRegisterCheck),
@@ -42,6 +42,21 @@
 			starlark.MakeInt(Version[0]), starlark.MakeInt(Version[1]), starlark.MakeInt(Version[2]),
 		},
 	}
+	if !execed {
+		// Disallow loaded files from calling `shac.register_check`. Only the
+		// top-level shac.star file can call `shac.register_check`; outside
+		// shac.star, `shac.register_check` cannot be called at the top level,
+		// only in functions that are loaded and called by shac.star.
+		//
+		// This prevents libraries from forcibly registering checks for all
+		// users, and forces users to be somewhat explicit about which checks
+		// are registered.
+		ret["register_check"] = newBuiltinNone("shac.register_check",
+			func(ctx context.Context, s *shacState, name string, args starlark.Tuple, kwargs []starlark.Tuple) error {
+				return fmt.Errorf("cannot be called from a loaded file")
+			})
+	}
+	return ret
 }
 
 // shacRegisterCheck implements native function shac.register_check().
diff --git a/internal/engine/testdata/fail_or_throw/load-register_check.star b/internal/engine/testdata/fail_or_throw/load-register_check.star
new file mode 100644
index 0000000..f4f9ff6
--- /dev/null
+++ b/internal/engine/testdata/fail_or_throw/load-register_check.star
@@ -0,0 +1,16 @@
+# 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.
+
+# Loading a file that registers a check is not allowed.
+load("./fail-check.star", "cb")