Validate module name and version derived from PR paths in bcr_presubmit.py (#2759)

`module_name` and `module_version` in `bcr_presubmit.py` are derived
from a pull request's changed file paths (`modules/<name>/<version>/`)
via `get_target_modules()`, using a regex whose only constraint is "no
slash" (`modules\/([^\/]+)\/([^\/]+)\/`). Both values can therefore
contain arbitrary shell and Starlark metacharacters.

Two sinks interpolate these values unescaped:
- The shell command string built in `add_presubmit_jobs()` (partially
addressed by #2739's `shlex.quote` fix at that one call site).
- The generated `MODULE.bazel` content in `create_anonymous_repo()`:
  ```python
scratch_file(root, "MODULE.bazel", ["bazel_dep(name = '%s', version =
'%s')" % (module_name, module_version)])
  ```
A `module_name`/`module_version` containing a single quote closes the
Starlark string literal early, letting the rest of the payload be parsed
as new top-level `MODULE.bazel` statements (e.g. `load(...)` + a
repository rule invocation), evaluated when `bazel vendor` runs during
`anonymous_module_runner`/`test_module_runner`. This sink is unaffected
by #2739, since `shlex.quote` only protects shell-argument parsing, not
the raw string value that later reaches this `%`-format call.

This patch rejects any `module_name`/`module_version` that doesn't match
the character set Bzlmod itself accepts
(https://bazel.build/external/module#module_name, `#version`) at the
point they're derived from the PR path in
`get_target_modules()`/`get_modules_with_metadata_change()`. Validating
at the source closes both the already-partially-fixed shell sink and the
still-open `MODULE.bazel` sink (and any future sink in this file) at a
single choke point, rather than escaping per call site.

Normal module names/versions (e.g. `rules_foo` / `1.2.3`, `protobuf` /
`27.0-rc1`) are unaffected. Anything containing quotes, backticks,
semicolons, dollar signs, spaces, or uppercase letters in the name is
now rejected before it reaches any downstream sink.
diff --git a/buildkite/bazel-central-registry/bcr_presubmit.py b/buildkite/bazel-central-registry/bcr_presubmit.py
index be7ff62..cc09783 100755
--- a/buildkite/bazel-central-registry/bcr_presubmit.py
+++ b/buildkite/bazel-central-registry/bcr_presubmit.py
@@ -34,6 +34,22 @@
 
 BCR_REPO_DIR = pathlib.Path(os.getcwd())
 
+# Bazel module name / version character sets, per
+# https://bazel.build/external/module#module_name and #version. Anything
+# outside these sets is not a name/version Bzlmod itself would ever accept,
+# so rejecting it here is safe for legitimate modules and closes every sink
+# in this file (shell command construction, generated MODULE.bazel content,
+# and the modules/<name>/<version>/ path getters below) to a single choke
+# point instead of escaping/quoting at each call site individually.
+MODULE_NAME_RE = re.compile(r"^[a-z]([a-z0-9._-]*[a-z0-9])?$")
+MODULE_VERSION_RE = re.compile(r"^[A-Za-z0-9]+(?:[._+-][A-Za-z0-9]+)*$")
+
+
+def is_valid_module_identifier(module_name, module_version):
+    return bool(MODULE_NAME_RE.match(module_name)) and bool(
+        MODULE_VERSION_RE.match(module_version)
+    )
+
 BUILDKITE_ORG = os.environ.get("BUILDKITE_ORGANIZATION_SLUG", "bazel")
 
 SCRIPT_URL = "https://raw.githubusercontent.com/bazelbuild/continuous-integration/{}/buildkite/bazel-central-registry/bcr_presubmit.py?{}".format(
@@ -87,7 +103,20 @@
     for line in output.decode("utf-8").split():
         s = re.match(r"modules\/([^\/]+)\/([^\/]+)\/", line)
         if s:
-            modules.add(s.groups())
+            module_name, module_version = s.groups()
+            # [^\/]+ only excludes "/", so a crafted PR path can smuggle
+            # shell/Starlark metacharacters into module_name/module_version
+            # (e.g. quotes, backticks, semicolons) that later get interpolated
+            # into generated commands and MODULE.bazel content elsewhere in
+            # this file. Reject anything that isn't a name/version Bzlmod
+            # itself would accept, rather than trusting the path shape alone.
+            if not is_valid_module_identifier(module_name, module_version):
+                raise BcrPipelineException(
+                    "Invalid module name or version derived from changed path %r: "
+                    "module_name=%r module_version=%r"
+                    % (line, module_name, module_version)
+                )
+            modules.add((module_name, module_version))
 
     return sorted(modules)
 
@@ -105,7 +134,13 @@
     for line in output.decode("utf-8").split():
         s = re.match(r"modules\/([^\/]+)\/metadata\.json", line)
         if s:
-            modules.add(s.groups()[0])
+            module_name = s.groups()[0]
+            if not MODULE_NAME_RE.match(module_name):
+                raise BcrPipelineException(
+                    "Invalid module name derived from changed path %r: module_name=%r"
+                    % (line, module_name)
+                )
+            modules.add(module_name)
 
     return sorted(modules)