Define a tool and a static rule to calculate the hash of a directory

The rule will be used in a follow up change that adds a directory in a
provider.

Test: m nothing
Bug: 381104942
Change-Id: I7147dcbc43e19840b2d73380785a01bda6643d85
diff --git a/android/defs.go b/android/defs.go
index 9f3fb1e..c4e3b99 100644
--- a/android/defs.go
+++ b/android/defs.go
@@ -102,6 +102,18 @@
 			Description: "concatenate files to $out",
 		})
 
+	// Calculates the hash of a directory and writes to a file.
+	// Note that the directory to calculate the hash is intentionally not listed as an input,
+	// to prevent adding directory as a ninja dependency. Thus, an implicit dependency to a file
+	// is required.
+	WriteDirectoryHash = pctx.AndroidStaticRule("WriteDirectoryHash",
+		blueprint.RuleParams{
+			Command:     "rm -f $out && ${calculateDirectoryHash} $dir $out",
+			CommandDeps: []string{"${calculateDirectoryHash}"},
+			Description: "Calculates the hash of a directory and writes to $out",
+		}, "dir",
+	)
+
 	// Used only when USE_GOMA=true is set, to restrict non-goma jobs to the local parallelism value
 	localPool = blueprint.NewBuiltinPool("local_pool")
 
@@ -118,4 +130,6 @@
 	pctx.VariableFunc("RBEWrapper", func(ctx PackageVarContext) string {
 		return ctx.Config().RBEWrapper()
 	})
+
+	pctx.HostBinToolVariable("calculateDirectoryHash", "calculate_directory_hash")
 }
diff --git a/scripts/Android.bp b/scripts/Android.bp
index d39c84a..49e6b73 100644
--- a/scripts/Android.bp
+++ b/scripts/Android.bp
@@ -327,3 +327,11 @@
         "rustc_linker.py",
     ],
 }
+
+python_binary_host {
+    name: "calculate_directory_hash",
+    main: "calculate_directory_hash.py",
+    srcs: [
+        "calculate_directory_hash.py",
+    ],
+}
diff --git a/scripts/calculate_directory_hash.py b/scripts/calculate_directory_hash.py
new file mode 100755
index 0000000..d4802d8
--- /dev/null
+++ b/scripts/calculate_directory_hash.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 The Android Open Source Project
+#
+# 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.
+#
+"""A tool for calculating the hash of a directory based on file contents and metadata."""
+
+import argparse
+import hashlib
+import os
+import stat
+
+def calculate_hash(directory: str) -> str:
+    """
+    Calculates the hash of a directory, including file contents and metadata.
+
+    Following informations are taken into consideration:
+    * Name: The file or directory name.
+    * File Type: Whether it's a regular file, directory, symbolic link, etc.
+    * Size: The size of the file in bytes.
+    * Permissions: The file's access permissions (read, write, execute).
+    * Content Hash (for files): The SHA-1 hash of the file's content.
+    """
+
+    output = []
+    for root, _, files in os.walk(directory):
+        for file in files:
+            filepath = os.path.join(root, file)
+            file_stat = os.lstat(filepath)
+            stat_info = f"{filepath} {stat.filemode(file_stat.st_mode)} {file_stat.st_size}"
+
+            if os.path.islink(filepath):
+                stat_info += os.readlink(filepath)
+            elif os.path.isfile(filepath):
+                with open(filepath, "rb") as f:
+                    file_hash = hashlib.sha1(f.read()).hexdigest()
+                stat_info += f" {file_hash}"
+
+            output.append(stat_info)
+
+    return hashlib.sha1("\n".join(sorted(output)).encode()).hexdigest()
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Calculate the hash of a directory.")
+    parser.add_argument("directory", help="Path to the directory")
+    parser.add_argument("output_file", help="Path to the output file")
+    args = parser.parse_args()
+
+    hash_value = calculate_hash(args.directory)
+    with open(args.output_file, "w") as f:
+        f.write(hash_value)