pre-upload: Check json file in project_public repo

Add a check to make sure the battery config json file matches
the rule we require in BCIC.

BUG=b:356543760
TEST=(1)./run_tests pre-upload_unittest.py -k CheckJsonKeyPrefixTest
     (2)manual test: upload an invalid json and fail
     (3)manual test: upload a valid json and pass
     (4)manual test: upload a prefix error json and fail

Change-Id: Ia2edd518301931370b8a27775bdf79f73ca43530
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/repohooks/+/5884233
Tested-by: Tzu-Min Sun <jimmysun@chromium.org>
Commit-Queue: Tzu-Min Sun <jimmysun@chromium.org>
Reviewed-by: Alex Klein <saklein@chromium.org>
diff --git a/pre-upload.py b/pre-upload.py
index a382a43..6b4bf61 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -18,6 +18,7 @@
 import fnmatch
 import functools
 import itertools
+import json
 import os
 from pathlib import Path
 import re
@@ -2836,6 +2837,62 @@
     return None
 
 
+def _check_json_key_prefix(project: Project, commit: str):
+    """Checks that modified .json files are valid battery configs for BCIC.
+
+    This hook performs two main checks on modified .json files:
+    1. Validates that the file content is parseable as valid JSON.
+    2. Ensures that no pair of top-level keys in the JSON data are prefixes of
+       each other.
+
+    Args:
+        project: The Project to look at
+        commit: The commit to look at
+
+    Returns:
+        None if all checks pass, otherwise a HookFailure object with details
+        of the errors.
+    """
+    if project.name != "chromiumos/project":
+        return None
+
+    files = _filter_files(
+        _get_affected_files(commit, relative=True), [r".*\.json$"]
+    )
+
+    json_error_list = []
+    prefix_error_dict = {}
+    for file in files:
+        try:
+            json_file = json.loads(_get_file_content(file, commit))
+            keys = list(json_file.keys())
+            file_prefix_errors = []
+            for i, key1 in enumerate(keys):
+                for key2 in keys[i + 1 :]:
+                    if key1.startswith(key2) or key2.startswith(key1):
+                        file_prefix_errors.append(sorted([key1, key2]))
+            if file_prefix_errors:
+                prefix_error_dict[file] = file_prefix_errors
+
+        except json.JSONDecodeError as e:
+            json_error_list.append(f"{file}: {e}")
+
+    if json_error_list:
+        return errors.HookFailure("Invalid JSON file(s):", json_error_list)
+
+    if prefix_error_dict:
+        prefix_error_list = []
+        for file, prefix_errors in prefix_error_dict.items():
+            for key1, key2 in prefix_errors:
+                prefix_error_list.append(
+                    f"{file}: Key '{key1}' is a prefix of Key '{key2}'"
+                )
+        return errors.HookFailure(
+            "JSON file(s) with prefix errors:", prefix_error_list
+        )
+    return None
+
+
 def _check_cl_size(
     _project: Project, commit: str, options: Sequence[str] = ()
 ) -> None:
@@ -2895,6 +2952,7 @@
     _check_exec_files,
     _check_for_uprev,
     _check_git_cl_presubmit,
+    _check_json_key_prefix,
     _check_keywords,
     _check_layout_conf,
     _check_no_extra_blank_lines,
diff --git a/pre-upload_unittest.py b/pre-upload_unittest.py
index 66e893f..7d0a467 100644
--- a/pre-upload_unittest.py
+++ b/pre-upload_unittest.py
@@ -3424,3 +3424,69 @@
                 "README.md:[Hook Overrides]"
             ),
         )
+
+
+class CheckJsonKeyPrefixTest(PreUploadTestCase, cros_test_lib.TempDirTestCase):
+    """Tests for _check_json_key_prefix."""
+
+    def setUp(self):
+        self.project = pre_upload.Project(
+            name="chromiumos/project", dir=self.tempdir, remote=None
+        )
+        # Ugly hack since self.tempdir is not in the chroot.
+        self.PatchObject(path_util, "ToChrootPath", side_effect=lambda x: x)
+        self.file_mock = self.PatchObject(pre_upload, "_get_affected_files")
+        self.content_mock = self.PatchObject(pre_upload, "_get_file_content")
+
+    def testJsonFormat(self):
+        """Verify that we can check the format of a json file."""
+        self.file_mock.return_value = [
+            f"{self.project.dir}/path/to/my/json_files/foo.json"
+        ]
+        self.content_mock.return_value = """
+{
+    "a": 1,
+    "b": 2,
+    "c": 3
+}
+        """
+        ret = pre_upload._check_json_key_prefix(self.project, "COMMIT")
+        self.assertIsNone(ret)
+
+    def testInvalidJsonFormat(self):
+        """Verify that we can check the format of a json file."""
+        self.file_mock.return_value = [
+            f"{self.project.dir}/path/to/my/json_files/foo.json"
+        ]
+        self.content_mock.return_value = """
+{
+    "a": 1,
+    "b": 2,
+    "c": 3,
+}
+        """
+        ret = pre_upload._check_json_key_prefix(self.project, "COMMIT")
+        self.assertTrue(ret)
+        self.assertEqual("Invalid JSON file(s):", ret.msg)
+
+    def testPrefixError(self):
+        """Verify that no pair of keys in the json file are prefixes."""
+        self.file_mock.return_value = [
+            f"{self.project.dir}/path/to/my/json_files/foo.json"
+        ]
+        self.content_mock.return_value = """
+{
+    "abc": 1,
+    "ab": 2,
+    "c": 3
+}
+        """
+        ret = pre_upload._check_json_key_prefix(self.project, "COMMIT")
+        self.assertTrue(ret)
+        self.assertEqual(
+            ret.items,
+            [
+                f"{self.project.dir}/path/to/my/json_files/foo.json: "
+                "Key 'ab' is a prefix of Key 'abc'"
+            ],
+        )