Repository: theupdateframework/python-tuf (python)

Issue:
MetaFile is missing from_bytes() or equivalent
We already have `TargetFile.from_data() / TargetFile.from_file()` to calculate hashes and length for a target.
We should optionally support hashes and length for Metafile too (but likely the API is not same, see below).

This hasn't come up so far because we haven't really used the optional hashes in our metafiles... it did come up in https://github.com/theupdateframework/go-tuf/pull/228.

Notes:
* presumably most of the code currently in `TargetFile.from_data()`can be shared somehow
* the situation is different in MetaFile and TargetFile for 2 reasons:
  * `MetaFile` needs version that comes from a `Metadata` -- obviously the implementation could parse the bytes it gets to construct Metadata... but that's not ideal because we can expect the Metadata is already in memory, reparsing is wasteful. The go-tuf generator code is a fine example of this.
  * A valid `MetaFile` only needs version: the API should not automatically lead to using hashes

potential API (_maybe_ add `from_file()` to be consistent):

```python
class MetaFile(BaseFile):

    @classmethod
    def from_data(
        cls,
        version: int,
        data: Optional[Union[bytes, IO[bytes]]] = None,
        hash_algorithms: Optional[List[str]] = None,
    ) -> "MetaFile"
        # data is the Metadata bytes that the metafile represents.
        # Hashes and length should be included only if data is not None
```

Pull request description:
enhancement: Add from_data() method to MetaFile
Signed-off-by: Satvik Vemuganti <vemugantisesha@iitbhilai.ac.in>

Fixes #1893 

**Description of the changes being introduced by the pull request**:
There already exists a `TargetFile.from_data()` / `TargetFile.from_file()` to calculate hashes and length for a target.
This update enables support for hashes and length for Metafile too.
This PR introduces a static method in `BaseFile` as suggested by @jku in #2186. Also taking from the same comment, the `MetaFile.from_file()` method has not been implemented.

**The pull request fulfills the following
requirements**:

- [X] The code follows the [Code Style Guidelines](https://github.com/secure-systems-lab/code-style-guidelines#code-style-guidelines)
- [X] Tests have been added for the bug fix or new feature
- [X] Docs have been added for the bug fix or new feature




Patch:
diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py
index 45840512..8c5aebe8 100644
--- a/tuf/api/metadata.py
+++ b/tuf/api/metadata.py
@@ -900,6 +900,41 @@ def _validate_length(length: int) -> None:
         if length < 0:
             raise ValueError(f"Length must be >= 0, got {length}")
 
+    @staticmethod
+    def _get_length_and_hashes(
+        data: Union[bytes, IO[bytes]], hash_algorithms: Optional[List[str]]
+    ) -> Tuple[int, Dict[str, str]]:
+        """Calculate length and hashes of ``data``."""
+        if isinstance(data, bytes):
+            length = len(data)
+        else:
+            data.seek(0, io.SEEK_END)
+            length = data.tell()
+
+        hashes = {}
+
+        if hash_algorithms is None:
+            hash_algorithms = [sslib_hash.DEFAULT_HASH_ALGORITHM]
+
+        for algorithm in hash_algorithms:
+            try:
+                if isinstance(data, bytes):
+                    digest_object = sslib_hash.digest(algorithm)
+                    digest_object.update(data)
+                else:
+                    digest_object = sslib_hash.digest_fileobject(
+                        data, algorithm
+                    )
+            except (
+                sslib_exceptions.UnsupportedAlgorithmError,
+                sslib_exceptions.FormatError,
+            ) as e:
+                raise ValueError(f"Unsupported algorithm '{algorithm}'") from e
+
+            hashes[algorithm] = digest_object.hexdigest()
+
+        return (length, hashes)
+
 
 class MetaFile(BaseFile):
     """A container with information about a particular metadata file.
@@ -966,6 +1001,28 @@ def from_dict(cls, meta_dict: Dict[str, Any]) -> "MetaFile":
         # All fields left in the meta_dict are unrecognized.
         return cls(version, length, hashes, meta_dict)
 
+    @classmethod
+    def from_data(
+        cls,
+        version: int,
+        data: Union[bytes, IO[bytes]],
+        hash_algorithms: List[str],
+    ) -> "MetaFile":
+        """Creates MetaFile object from bytes.
+        This constructor should only be used if hashes are wanted.
+        By default, MetaFile(ver) should be used.
+        Args:
+            version: Version of the metadata file.
+            data: Metadata bytes that the metafile represents.
+            hash_algorithms: Hash algorithms to create the hashes with. If not
+            specified, the securesystemslib default hash algorithm is used.
+        Raises:
+            ValueError: The hash algorithms list contains an unsupported
+            algorithm.
+        """
+        length, hashes = cls._get_length_and_hashes(data, hash_algorithms)
+        return cls(version, length, hashes)
+
     def to_dict(self) -> Dict[str, Any]:
         """Return the dictionary representation of self."""
         res_dict: Dict[str, Any] = {
@@ -1693,34 +1750,7 @@ def from_data(
             ValueError: The hash algorithms list contains an unsupported
                 algorithm.
         """
-        if isinstance(data, bytes):
-            length = len(data)
-        else:
-            data.seek(0, io.SEEK_END)
-            length = data.tell()
-
-        hashes = {}
-
-        if hash_algorithms is None:
-            hash_algorithms = [sslib_hash.DEFAULT_HASH_ALGORITHM]
-
-        for algorithm in hash_algorithms:
-            try:
-                if isinstance(data, bytes):
-                    digest_object = sslib_hash.digest(algorithm)
-                    digest_object.update(data)
-                else:
-                    digest_object = sslib_hash.digest_fileobject(
-                        data, algorithm
-                    )
-            except (
-                sslib_exceptions.UnsupportedAlgorithmError,
-                sslib_exceptions.FormatError,
-            ) as e:
-                raise ValueError(f"Unsupported algorithm '{algorithm}'") from e
-
-            hashes[algorithm] = digest_object.hexdigest()
-
+        length, hashes = cls._get_length_and_hashes(data, hash_algorithms)
         return cls(length, hashes, target_file_path)
 
     def verify_length_and_hashes(self, data: Union[bytes, IO[bytes]]) -> None:


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 14ae12c9..4d964d72 100755
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -34,6 +34,7 @@
     Delegations,
     Key,
     Metadata,
+    MetaFile,
     Root,
     Signature,
     Snapshot,
@@ -725,6 +726,29 @@ def test_targetfile_from_data(self) -> None:
         targetfile_from_data = TargetFile.from_data(target_file_path, data)
         targetfile_from_data.verify_length_and_hashes(data)
 
+    def test_metafile_from_data(self) -> None:
+        data = b"Inline test content"
+
+        # Test with a valid hash algorithm
+        metafile = MetaFile.from_data(1, data, ["sha256"])
+        metafile.verify_length_and_hashes(data)
+
+        # Test with an invalid hash algorithm
+        with self.assertRaises(ValueError):
+            metafile = MetaFile.from_data(1, data, ["invalid_algorithm"])
+            metafile.verify_length_and_hashes(data)
+
+        self.assertEqual(
+            metafile,
+            MetaFile(
+                1,
+                19,
+                {
+                    "sha256": "fcee2e6d56ab08eab279016f7db7e4e1d172ccea78e15f4cf8bd939991a418fa"
+                },
+            ),
+        )
+
     def test_targetfile_get_prefixed_paths(self) -> None:
         target = TargetFile(100, {"sha256": "abc", "md5": "def"}, "a/b/f.ext")
         self.assertEqual(