This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git


The following commit(s) were added to refs/heads/main by this push:
     new 718c7908b feat(encryption): [9/N] Read encrypted manifest lists (#4159)
718c7908b is described below

commit 718c7908b36117974257e3d214959bffcada14a1
Author: Xander <[email protected]>
AuthorDate: Fri Nov 6 23:40:36 2026 +0000

    feat(encryption): [9/N] Read encrypted manifest lists (#4159)
    
    * Read encrypted manifest lists
    
    * Pass encryption manager to remaining manifest list reads
    
    inspect.partitions(), the CLI files output and purge_table read manifest
    lists without the encryption manager, so they failed on encrypted tables.
    purge_table now builds the manager before dropping the table, so a missing
    KMS client no longer leaves a dropped table with unpurged files.
    
    * Reuse the encryption manager until table metadata changes
    
    Building a manager on every access discarded its KEK cache, so each 
manifest list read called the KMS again.
    
    * Read encrypted and plain manifest lists through one path in 
Snapshot.manifests
    
    * Track the encryption manager's metadata in a NamedTuple
    
    Replace the bare (metadata, manager) tuple with a NamedTuple so the
    property reads as "rebuild when the metadata changed since it was built",
    while still swapping the pair in a single assignment.
    
    Co-authored-by: Copilot App <[email protected]>
    
    ---------
    
    Co-authored-by: Kevin Liu <[email protected]>
    Co-authored-by: Copilot App <[email protected]>
---
 pyiceberg/catalog/__init__.py                      |   3 +-
 pyiceberg/cli/output.py                            |   3 +-
 pyiceberg/encryption/io.py                         |  50 ++++++++++++++
 pyiceberg/manifest.py                              |   7 +-
 pyiceberg/table/__init__.py                        |  54 +++++++++++++--
 pyiceberg/table/inspect.py                         |  13 ++--
 pyiceberg/table/snapshots.py                       |  19 +++++-
 tests/catalog/test_catalog_behaviors.py            |  15 +++++
 tests/cli/test_output.py                           |   9 ++-
 tests/conftest.py                                  |  73 +++++++++++++++++++++
 tests/encryption/test_stream.py                    |  31 +++++++++
 tests/table/encryption/README.md                   |  25 +++++++
 .../encryption/manifest-list-v3-encrypted.avro     | Bin 0 -> 1568 bytes
 tests/table/test_init.py                           |  58 ++++++++++++++++
 tests/utils/test_manifest.py                       |  42 ++++++++++++
 15 files changed, 384 insertions(+), 18 deletions(-)

diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py
index cdb4d26b3..ce0902f33 100644
--- a/pyiceberg/catalog/__init__.py
+++ b/pyiceberg/catalog/__init__.py
@@ -1026,13 +1026,14 @@ class MetastoreCatalog(Catalog, ABC):
     @override
     def purge_table(self, identifier: str | Identifier) -> None:
         table = self.load_table(identifier)
+        encryption_manager = table.encryption_manager
         self.drop_table(identifier)
         io = load_file_io(self.properties, table.metadata_location)
         metadata = table.metadata
         manifest_lists_to_delete = set()
         manifests_to_delete: list[ManifestFile] = []
         for snapshot in metadata.snapshots:
-            manifests_to_delete += snapshot.manifests(io)
+            manifests_to_delete += snapshot.manifests(io, encryption_manager)
             manifest_lists_to_delete.add(snapshot.manifest_list)
 
         manifest_paths_to_delete = {manifest.manifest_path for manifest in 
manifests_to_delete}
diff --git a/pyiceberg/cli/output.py b/pyiceberg/cli/output.py
index cfb00abb7..f2e6ddaed 100644
--- a/pyiceberg/cli/output.py
+++ b/pyiceberg/cli/output.py
@@ -165,13 +165,14 @@ class ConsoleOutput(Output):
 
         snapshot_tree = Tree(f"Snapshots: {'.'.join(table.name())}")
         io = table.io
+        encryption_manager = table.encryption_manager
 
         for snapshot in snapshots:
             list_tree = snapshot_tree.add(
                 f"Snapshot {snapshot.snapshot_id}, schema 
{snapshot.schema_id}: {snapshot.manifest_list}"
             )
 
-            manifest_list = snapshot.manifests(io)
+            manifest_list = snapshot.manifests(io, encryption_manager)
             for manifest in manifest_list:
                 manifest_tree = list_tree.add(f"Manifest: 
{manifest.manifest_path}")
                 for manifest_entry in manifest.fetch_manifest_entry(io, 
discard_deleted=False):
diff --git a/pyiceberg/encryption/io.py b/pyiceberg/encryption/io.py
new file mode 100644
index 000000000..1ba9dd5b7
--- /dev/null
+++ b/pyiceberg/encryption/io.py
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Input files for metadata encrypted with AGS1 streams."""
+
+from __future__ import annotations
+
+import io
+
+from pyiceberg.encryption.key_metadata import StandardKeyMetadata
+from pyiceberg.encryption.stream import _decrypt_ags1
+from pyiceberg.io import InputFile, InputStream
+
+
+class EncryptedInputFile(InputFile):
+    """Decrypts an AGS1 encrypted file, such as a manifest list, when it is 
opened."""
+
+    def __init__(self, input_file: InputFile, key_metadata: 
StandardKeyMetadata) -> None:
+        super().__init__(input_file.location)
+        self._input_file = input_file
+        self._key_metadata = key_metadata
+
+    def __len__(self) -> int:
+        """Return the length of the decrypted file, in bytes."""
+        return len(self._decrypt())
+
+    def exists(self) -> bool:
+        """Check whether the encrypted file exists."""
+        return self._input_file.exists()
+
+    def open(self, seekable: bool = True) -> InputStream:
+        """Read and decrypt the whole file, authenticating every block before 
returning any of it."""
+        return io.BytesIO(self._decrypt())
+
+    def _decrypt(self) -> bytes:
+        with self._input_file.open(seekable=False) as stream:
+            return _decrypt_ags1(stream.read(), self._key_metadata)
diff --git a/pyiceberg/manifest.py b/pyiceberg/manifest.py
index 0881273c4..3975c9c2a 100644
--- a/pyiceberg/manifest.py
+++ b/pyiceberg/manifest.py
@@ -34,6 +34,8 @@ from pydantic_core import to_json
 from pyiceberg.avro.codecs import AVRO_CODEC_KEY, AvroCompressionCodec
 from pyiceberg.avro.file import AvroFile, AvroOutputFile
 from pyiceberg.conversions import to_bytes
+from pyiceberg.encryption.io import EncryptedInputFile
+from pyiceberg.encryption.key_metadata import StandardKeyMetadata
 from pyiceberg.exceptions import ValidationError
 from pyiceberg.io import FileIO, InputFile, OutputFile
 from pyiceberg.partitioning import PartitionSpec
@@ -998,7 +1000,7 @@ def clear_manifest_cache() -> None:
     _manifest_cache.clear()
 
 
-def _manifests(io: FileIO, manifest_list: str) -> tuple[ManifestFile, ...]:
+def _manifests(io: FileIO, manifest_list: str, key_metadata: 
StandardKeyMetadata | None = None) -> tuple[ManifestFile, ...]:
     """Read manifests from a manifest list, reusing cached ManifestFile 
objects.
 
     Caches individual ManifestFile objects by manifest_path. This is 
memory-efficient
@@ -1018,11 +1020,14 @@ def _manifests(io: FileIO, manifest_list: str) -> 
tuple[ManifestFile, ...]:
     Args:
         io: FileIO instance for reading the manifest list.
         manifest_list: Path to the manifest list file.
+        key_metadata: Decrypts the manifest list, if it is encrypted.
 
     Returns:
         A tuple of ManifestFile objects.
     """
     file = io.new_input(manifest_list)
+    if key_metadata is not None:
+        file = EncryptedInputFile(file, key_metadata)
     manifest_files = list(read_manifest_list(file))
 
     return tuple(_manifest_cache.get_or_cache(manifest_file) for manifest_file 
in manifest_files)
diff --git a/pyiceberg/table/__init__.py b/pyiceberg/table/__init__.py
index 73c435f07..df7aaf824 100644
--- a/pyiceberg/table/__init__.py
+++ b/pyiceberg/table/__init__.py
@@ -28,11 +28,12 @@ from dataclasses import dataclass
 from functools import cached_property
 from itertools import chain
 from types import TracebackType
-from typing import TYPE_CHECKING, Any, TypeVar
+from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
 
 from cachetools import LRUCache
 from pydantic import Field
 
+from pyiceberg.encryption.kms import KeyManagementClient
 from pyiceberg.exceptions import CommitFailedException, ValidationException
 from pyiceberg.expressions import AlwaysFalse, AlwaysTrue, And, 
BooleanExpression, EqualTo, IsNull, Or, Reference
 from pyiceberg.expressions.visitors import (
@@ -126,6 +127,7 @@ if TYPE_CHECKING:
 
     from pyiceberg.catalog import Catalog
     from pyiceberg.catalog.rest.scan_planning import RESTContentFile, 
RESTDeleteFile, RESTFileScanTask
+    from pyiceberg.encryption.manager import EncryptionManager
     from pyiceberg.table.update.snapshot import _SnapshotProducer
 
 logger = logging.getLogger(__name__)
@@ -1327,6 +1329,13 @@ class CommitTableResponse(IcebergBaseModel):
     metadata_location: str = Field(alias="metadata-location")
 
 
+class _CachedEncryptionManager(NamedTuple):
+    """An encryption manager, reused with its unwrapped KEKs until the table 
metadata it was built from changes."""
+
+    metadata: TableMetadata
+    manager: EncryptionManager | None
+
+
 class Table:
     """An Iceberg table."""
 
@@ -1345,6 +1354,7 @@ class Table:
         io: FileIO,
         catalog: Catalog,
         config: dict[str, str] = EMPTY_DICT,
+        kms_client: KeyManagementClient | None = None,
     ) -> None:
         self._identifier = identifier
         self.metadata = metadata
@@ -1352,6 +1362,26 @@ class Table:
         self.io = io
         self.catalog = catalog
         self.config = config
+        self._kms_client = kms_client
+        self._encryption_manager: _CachedEncryptionManager | None = None
+
+    @property
+    def encryption_manager(self) -> EncryptionManager | None:
+        """The table's encryption manager, or None if the table is not 
encrypted.
+
+        The manager is built from the current metadata, and rebuilt when the 
metadata changes.
+
+        Raises:
+            ValueError: If the table is encrypted but no key management client 
was provided.
+        """
+        from pyiceberg.encryption.manager import EncryptionManager
+
+        cached = self._encryption_manager
+        if cached is None or cached.metadata is not self.metadata:
+            cached = self._encryption_manager = _CachedEncryptionManager(
+                self.metadata, 
EncryptionManager.from_table_metadata(self._kms_client, self.metadata)
+            )
+        return cached.manager
 
     def transaction(self) -> Transaction:
         """Create a new transaction object to first stage the changes, and 
then commit them to the catalog.
@@ -1451,6 +1481,7 @@ class Table:
             catalog=self.catalog,
             table_identifier=self._identifier,
             table_config=self.config,
+            encryption_manager=self.encryption_manager,
         )
 
     def incremental_append_scan(
@@ -1506,6 +1537,7 @@ class Table:
             to_snapshot_id=to_snapshot_id_inclusive,
             options=options,
             limit=limit,
+            encryption_manager=self.encryption_manager,
         )
 
     @property
@@ -2073,6 +2105,7 @@ class BaseScan(ABC):
     case_sensitive: bool
     options: Properties
     limit: int | None
+    encryption_manager: EncryptionManager | None
 
     def __init__(
         self,
@@ -2083,6 +2116,7 @@ class BaseScan(ABC):
         case_sensitive: bool = True,
         options: Properties = EMPTY_DICT,
         limit: int | None = None,
+        encryption_manager: EncryptionManager | None = None,
     ):
         self.table_metadata = table_metadata
         self.io = io
@@ -2091,6 +2125,7 @@ class BaseScan(ABC):
         self.case_sensitive = case_sensitive
         self.options = options
         self.limit = limit
+        self.encryption_manager = encryption_manager
 
     @abstractmethod
     def plan_files(self) -> Iterable[ScanTask]: ...
@@ -2190,6 +2225,7 @@ class TableScan(BaseScan):
         catalog: Catalog | None = None,
         table_identifier: Identifier | None = None,
         table_config: Properties = EMPTY_DICT,
+        encryption_manager: EncryptionManager | None = None,
     ):
         super().__init__(
             table_metadata=table_metadata,
@@ -2199,6 +2235,7 @@ class TableScan(BaseScan):
             case_sensitive=case_sensitive,
             options=options,
             limit=limit,
+            encryption_manager=encryption_manager,
         )
         self.snapshot_id = snapshot_id
         self.catalog = catalog
@@ -2454,7 +2491,7 @@ class DataScan(TableScan):
         if not snapshot:
             return iter([])
 
-        return 
self._manifest_planner.plan_manifest_entries(snapshot.manifests(self.io))
+        return 
self._manifest_planner.plan_manifest_entries(snapshot.manifests(self.io, 
self.encryption_manager))
 
     def _should_use_server_side_planning(self) -> bool:
         """Check if server-side scan planning should be used for this scan."""
@@ -2493,7 +2530,7 @@ class DataScan(TableScan):
             if self.snapshot_id is not None:
                 raise ValueError(f"Snapshot not found: {self.snapshot_id}")
             return []
-        return self._manifest_planner.plan_files(snapshot.manifests(self.io))
+        return self._manifest_planner.plan_files(snapshot.manifests(self.io, 
self.encryption_manager))
 
     def plan_files(self) -> Iterable[FileScanTask]:
         """Plans the relevant files by filtering on the PartitionSpecs.
@@ -2609,6 +2646,8 @@ class IncrementalAppendScan(BaseScan):
             An integer representing the number of rows to
             return in the scan result. If None, fetches all
             matching rows.
+        encryption_manager:
+            Decrypts the manifest lists of encrypted snapshots.
     """
 
     from_snapshot_id: int | None
@@ -2627,6 +2666,7 @@ class IncrementalAppendScan(BaseScan):
         to_snapshot_id: int | None = None,
         options: Properties = EMPTY_DICT,
         limit: int | None = None,
+        encryption_manager: EncryptionManager | None = None,
     ):
         super().__init__(
             table_metadata=table_metadata,
@@ -2636,6 +2676,7 @@ class IncrementalAppendScan(BaseScan):
             case_sensitive=case_sensitive,
             options=options,
             limit=limit,
+            encryption_manager=encryption_manager,
         )
         self.from_snapshot_id = from_snapshot_id
         self.from_snapshot_inclusive = from_snapshot_inclusive
@@ -2685,7 +2726,7 @@ class IncrementalAppendScan(BaseScan):
             {
                 manifest_file
                 for snapshot in append_snapshots
-                for manifest_file in snapshot.manifests(self.io)
+                for manifest_file in snapshot.manifests(self.io, 
self.encryption_manager)
                 if manifest_file.content == ManifestContent.DATA and 
manifest_file.added_snapshot_id in append_snapshot_ids
             }
         )
@@ -2698,8 +2739,9 @@ class IncrementalAppendScan(BaseScan):
             options=self.options,
         ).plan_files(
             manifests=manifests,
-            manifest_entry_filter=lambda manifest_entry: 
manifest_entry.snapshot_id in append_snapshot_ids
-            and manifest_entry.status == ManifestEntryStatus.ADDED,
+            manifest_entry_filter=lambda manifest_entry: (
+                manifest_entry.snapshot_id in append_snapshot_ids and 
manifest_entry.status == ManifestEntryStatus.ADDED
+            ),
         )
 
     def to_arrow(self) -> pa.Table:
diff --git a/pyiceberg/table/inspect.py b/pyiceberg/table/inspect.py
index e580b66dc..d51079fc9 100644
--- a/pyiceberg/table/inspect.py
+++ b/pyiceberg/table/inspect.py
@@ -232,7 +232,7 @@ class InspectTable:
 
         entries = []
         snapshot = self._get_snapshot(snapshot_id)
-        for manifest in snapshot.manifests(self.tbl.io):
+        for manifest in snapshot.manifests(self.tbl.io, 
self.tbl.encryption_manager):
             for entry in manifest.fetch_manifest_entry(io=self.tbl.io, 
discard_deleted=False):
                 column_sizes = entry.data_file.column_sizes or {}
                 value_counts = entry.data_file.value_counts or {}
@@ -383,7 +383,7 @@ class InspectTable:
         )
 
         snapshot = self._get_snapshot(snapshot_id)
-        spec_ids = {manifest.partition_spec_id for manifest in 
snapshot.manifests(self.tbl.io)}
+        spec_ids = {manifest.partition_spec_id for manifest in 
snapshot.manifests(self.tbl.io, self.tbl.encryption_manager)}
         partition_record = self.tbl.metadata.specs_struct(spec_ids=spec_ids)
         has_partitions = len(partition_record.fields) > 0
 
@@ -404,6 +404,7 @@ class InspectTable:
             row_filter=row_filter,
             case_sensitive=case_sensitive,
             snapshot_id=snapshot.snapshot_id,
+            encryption_manager=self.tbl.encryption_manager,
         )
 
         partitions_map: dict[tuple[str, Any], Any] = {}
@@ -590,7 +591,7 @@ class InspectTable:
         specs = self.tbl.metadata.specs()
         manifests = []
         if snapshot:
-            for manifest in snapshot.manifests(self.tbl.io):
+            for manifest in snapshot.manifests(self.tbl.io, 
self.tbl.encryption_manager):
                 is_data_file = manifest.content == ManifestContent.DATA
                 is_delete_file = manifest.content == ManifestContent.DELETES
                 manifest_row = {
@@ -882,7 +883,8 @@ class InspectTable:
         executor = ExecutorFactory.get_or_create()
         results = list(
             executor.map(
-                lambda manifest_list: 
self._get_files_from_manifest(manifest_list, data_file_filter), 
snapshot.manifests(io)
+                lambda manifest_list: 
self._get_files_from_manifest(manifest_list, data_file_filter),
+                snapshot.manifests(io, self.tbl.encryption_manager),
             )
         )
         return pa.concat_tables(results)
@@ -970,7 +972,8 @@ class InspectTable:
             return pa.Table.from_pylist([], schema=self._get_files_schema())
 
         executor = ExecutorFactory.get_or_create()
-        manifest_lists = executor.map(lambda snapshot: 
snapshot.manifests(self.tbl.io), snapshots)
+        encryption_manager = self.tbl.encryption_manager
+        manifest_lists = executor.map(lambda snapshot: 
snapshot.manifests(self.tbl.io, encryption_manager), snapshots)
 
         unique_manifests = {(manifest.manifest_path, manifest) for 
manifest_list in manifest_lists for manifest in manifest_list}
 
diff --git a/pyiceberg/table/snapshots.py b/pyiceberg/table/snapshots.py
index 66d3d7f23..624ce486b 100644
--- a/pyiceberg/table/snapshots.py
+++ b/pyiceberg/table/snapshots.py
@@ -32,6 +32,8 @@ from pyiceberg.partitioning import 
UNPARTITIONED_PARTITION_SPEC, PartitionSpec
 from pyiceberg.schema import Schema
 
 if TYPE_CHECKING:
+    from pyiceberg.encryption.key_metadata import StandardKeyMetadata
+    from pyiceberg.encryption.manager import EncryptionManager
     from pyiceberg.table.metadata import TableMetadata
 from pyiceberg.typedef import IcebergBaseModel
 
@@ -289,9 +291,20 @@ class Snapshot(IcebergBaseModel):
         filtered_fields = [field for field in fields if field is not None]
         return f"Snapshot({', '.join(filtered_fields)})"
 
-    def manifests(self, io: FileIO) -> list[ManifestFile]:
-        """Return the manifests for the given snapshot."""
-        return list(_manifests(io, self.manifest_list))
+    def manifests(self, io: FileIO, encryption_manager: EncryptionManager | 
None = None) -> list[ManifestFile]:
+        """Return the manifests for the given snapshot.
+
+        Args:
+            io: Reads the manifest list.
+            encryption_manager: Decrypts the manifest list, required when the 
snapshot has a `key-id`.
+        """
+        key_metadata: StandardKeyMetadata | None = None
+        if self.key_id is not None:
+            if encryption_manager is None:
+                raise ValueError(f"Snapshot {self.snapshot_id} is encrypted, 
but no encryption manager was provided")
+            key_metadata = 
encryption_manager.decrypt_manifest_list_key_metadata(self.key_id)
+
+        return list(_manifests(io, self.manifest_list, key_metadata))
 
 
 class MetadataLogEntry(IcebergBaseModel):
diff --git a/tests/catalog/test_catalog_behaviors.py 
b/tests/catalog/test_catalog_behaviors.py
index 1148ca852..5bd756d74 100644
--- a/tests/catalog/test_catalog_behaviors.py
+++ b/tests/catalog/test_catalog_behaviors.py
@@ -19,6 +19,7 @@
 Consolidated behavior tests for InMemoryCatalog and SqlCatalog.
 """
 
+import json
 import os
 from collections.abc import Generator
 from pathlib import Path
@@ -505,6 +506,20 @@ def test_purge_table(catalog: Catalog, 
table_schema_simple: Schema, test_table_i
         catalog.load_table(test_table_identifier)
 
 
+def test_purge_encrypted_table_without_kms_client(
+    catalog: Catalog, test_table_identifier: Identifier, 
example_table_metadata_v3_encrypted: dict[str, Any], tmp_path: Path
+) -> None:
+    metadata_location = tmp_path / "00000-encrypted.metadata.json"
+    
metadata_location.write_text(json.dumps(example_table_metadata_v3_encrypted))
+    catalog.create_namespace(Catalog.namespace_from(test_table_identifier))
+    catalog.register_table(test_table_identifier, str(metadata_location))
+
+    with pytest.raises(ValueError, match="no key management client was 
provided"):
+        catalog.purge_table(test_table_identifier)
+
+    assert catalog.table_exists(test_table_identifier)
+
+
 # List tables tests
 
 
diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py
index 7b27a02c5..8dd8e0dae 100644
--- a/tests/cli/test_output.py
+++ b/tests/cli/test_output.py
@@ -18,7 +18,8 @@ import json
 
 import pytest
 
-from pyiceberg.cli.output import JsonOutput
+from pyiceberg.cli.output import ConsoleOutput, JsonOutput
+from pyiceberg.table import Table
 from pyiceberg.table.refs import SnapshotRefType
 
 
@@ -46,3 +47,9 @@ def test_json_describe_refs_emits_one_object_per_ref(capsys: 
pytest.CaptureFixtu
             "max_ref_age_ms": "forever",
         }
     ]
+
+
+def test_files_of_encrypted_table(table_v3_encrypted: Table, capsys: 
pytest.CaptureFixture[str]) -> None:
+    ConsoleOutput().files(table_v3_encrypted, history=True)
+
+    assert "Snapshot 1" in capsys.readouterr().out
diff --git a/tests/conftest.py b/tests/conftest.py
index f243397a6..05d50925c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2652,6 +2652,79 @@ def table_v3(example_table_metadata_v3: dict[str, Any]) 
-> Table:
     )
 
 
[email protected]
+def example_table_metadata_v3_encrypted() -> dict[str, Any]:
+    """Metadata of an encrypted table written by iceberg-rust, whose manifest 
list is in tests/table/encryption."""
+    manifest_list = Path(__file__).parent / "table" / "encryption" / 
"manifest-list-v3-encrypted.avro"
+    return {
+        "format-version": 3,
+        "table-uuid": "9c12d441-03fe-4693-9a96-a0705ddf69c1",
+        "location": "memory:///table",
+        "last-sequence-number": 0,
+        "last-updated-ms": 1602638573590,
+        "last-column-id": 1,
+        "current-schema-id": 0,
+        "schemas": [{"type": "struct", "schema-id": 0, "fields": [{"id": 1, 
"name": "x", "required": True, "type": "long"}]}],
+        "default-spec-id": 0,
+        "partition-specs": [{"spec-id": 0, "fields": []}],
+        "last-partition-id": 1000,
+        "default-sort-order-id": 0,
+        "sort-orders": [{"order-id": 0, "fields": []}],
+        "properties": {"encryption.key-id": "master-1"},
+        "current-snapshot-id": 1,
+        "snapshots": [
+            {
+                "snapshot-id": 1,
+                "sequence-number": 0,
+                "timestamp-ms": 0,
+                "manifest-list": str(manifest_list),
+                "summary": {"operation": "append"},
+                "schema-id": 0,
+                "key-id": "1fb95f04-7f98-4b1f-a08f-e810cfb907ff",
+            }
+        ],
+        "encryption-keys": [
+            {
+                "key-id": "5b29941b-b3b4-4d57-8758-a9d1bf340b39",
+                "encrypted-key-metadata": 
"WVMIPwafypGJRgXOICFKcL+m8K4B40gEIJFsgC7rlitqN9PUpDeT19OZPs8=",
+                "encrypted-by-id": "master-1",
+                "properties": {"KEY_TIMESTAMP": "1781172301703"},
+            },
+            {
+                "key-id": "1fb95f04-7f98-4b1f-a08f-e810cfb907ff",
+                "encrypted-key-metadata": (
+                    
"XcrqLxxyf9a7FAFXM63u3MEH8CSWqh6g1H8fJD2/6zx0EjcPzPaP/5AtRpO2s6p2isV8s7a4m77zj+GIqNmIpbA2Xw=="
+                ),
+                "encrypted-by-id": "5b29941b-b3b4-4d57-8758-a9d1bf340b39",
+            },
+        ],
+        "snapshot-log": [{"snapshot-id": 1, "timestamp-ms": 0}],
+        "metadata-log": [],
+        "refs": {"main": {"snapshot-id": 1, "type": "branch"}},
+        "next-row-id": 0,
+    }
+
+
[email protected]
+def table_v3_encrypted(example_table_metadata_v3_encrypted: dict[str, Any]) -> 
Table:
+    from pyiceberg.catalog.noop import NoopCatalog
+    from pyiceberg.encryption.ciphers import SecureKey
+    from tests.encryption.memory_kms import MemoryKeyManagementClient
+
+    kms = MemoryKeyManagementClient()
+    kms.add_master_key("master-1", SecureKey(bytes(range(16))))
+
+    table_metadata = TableMetadataV3(**example_table_metadata_v3_encrypted)
+    return Table(
+        identifier=("database", "table"),
+        metadata=table_metadata,
+        metadata_location=f"{table_metadata.location}/uuid.metadata.json",
+        io=load_file_io(),
+        catalog=NoopCatalog("NoopCatalog"),
+        kms_client=kms,
+    )
+
+
 @pytest.fixture
 def table_v2_orc(example_table_metadata_v2: dict[str, Any]) -> Table:
     import copy
diff --git a/tests/encryption/test_stream.py b/tests/encryption/test_stream.py
index 2f96e7155..5211f39d1 100644
--- a/tests/encryption/test_stream.py
+++ b/tests/encryption/test_stream.py
@@ -15,8 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from dataclasses import replace
+from pathlib import Path
+
 import pytest
 
+from pyiceberg.encryption.io import EncryptedInputFile
 from pyiceberg.encryption.key_metadata import StandardKeyMetadata
 from pyiceberg.encryption.stream import (
     _BLOCK_OVERHEAD,
@@ -29,6 +33,7 @@ from pyiceberg.encryption.stream import (
     _encrypt_ags1,
     _stream_block_aad,
 )
+from pyiceberg.io.pyarrow import PyArrowFileIO
 
 KEY = b"0123456789012345"
 AAD_PREFIX = b"0123456789abcdef"
@@ -203,3 +208,29 @@ def test_decrypt_rejects_the_wrong_key() -> None:
 
     with pytest.raises(ValueError, match="wrong decryption key; or 
corrupt/tampered data"):
         decrypt(stream, key=b"1123456789012345")
+
+
+def test_encrypted_input_file(tmp_path: Path) -> None:
+    plaintext = b"manifest list contents"
+    encrypted = _encrypt_ags1(plaintext, KEY_METADATA)
+    path = tmp_path / "manifest-list.avro"
+    path.write_bytes(encrypted)
+
+    input_file = EncryptedInputFile(PyArrowFileIO().new_input(str(path)), 
replace(KEY_METADATA, file_length=len(encrypted)))
+
+    assert input_file.location == str(path)
+    assert input_file.exists()
+    assert len(input_file) == len(plaintext)
+    with input_file.open() as stream:
+        assert stream.read() == plaintext
+
+
+def test_encrypted_input_file_rejects_a_truncated_file(tmp_path: Path) -> None:
+    encrypted = _encrypt_ags1(b"manifest list contents", KEY_METADATA)
+    path = tmp_path / "manifest-list.avro"
+    path.write_bytes(encrypted[:-1])
+
+    input_file = EncryptedInputFile(PyArrowFileIO().new_input(str(path)), 
replace(KEY_METADATA, file_length=len(encrypted)))
+
+    with pytest.raises(ValueError, match="key metadata expects"):
+        input_file.open()
diff --git a/tests/table/encryption/README.md b/tests/table/encryption/README.md
new file mode 100644
index 000000000..ae20c650c
--- /dev/null
+++ b/tests/table/encryption/README.md
@@ -0,0 +1,25 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you 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.
+-->
+
+# Encrypted manifest list test data
+
+`manifest-list-v3-encrypted.avro` is an empty manifest list encrypted by 
iceberg-rust, copied from its
+`crates/iceberg/testdata/manifests_lists/`. It was written by iceberg-rust's 
`EncryptionManager`, with a memory KMS
+holding master key `master-1` = bytes `0x00` to `0x0f`. Its keys and snapshot 
are in the
+`example_table_metadata_v3_encrypted` fixture in `tests/conftest.py`.
diff --git a/tests/table/encryption/manifest-list-v3-encrypted.avro 
b/tests/table/encryption/manifest-list-v3-encrypted.avro
new file mode 100644
index 000000000..4963f0eff
Binary files /dev/null and 
b/tests/table/encryption/manifest-list-v3-encrypted.avro differ
diff --git a/tests/table/test_init.py b/tests/table/test_init.py
index ff7ecd7c3..481326b44 100644
--- a/tests/table/test_init.py
+++ b/tests/table/test_init.py
@@ -2058,6 +2058,64 @@ def test_serialize_encryption_key_updates() -> None:
     assert TypeAdapter(TableUpdate).validate_json(remove.model_dump_json()) == 
remove
 
 
+def test_encryption_manager_of_unencrypted_table(table_v3: Table) -> None:
+    assert table_v3.encryption_manager is None
+
+
+def 
test_encryption_manager_requires_kms_client(example_table_metadata_v3_encrypted:
 dict[str, Any]) -> None:
+    table_metadata = 
TableMetadataUtil.parse_obj(example_table_metadata_v3_encrypted)
+    table = Table(("database", "table"), table_metadata, 
"s3://bucket/metadata.json", load_file_io(), NoopCatalog("noop"))
+
+    with pytest.raises(ValueError, match="no key management client was 
provided"):
+        table.scan()
+
+
+def test_encryption_manager_follows_current_metadata(table_v3_encrypted: 
Table) -> None:
+    assert table_v3_encrypted.encryption_manager is not None
+
+    # Catalogs must not let encryption.key-id be removed, but removing it 
shows the manager follows the current metadata
+    table_v3_encrypted.metadata = 
table_v3_encrypted.metadata.model_copy(update={"properties": {}})
+
+    assert table_v3_encrypted.encryption_manager is None
+
+
+def 
test_encryption_manager_is_reused_until_metadata_changes(table_v3_encrypted: 
Table, monkeypatch: pytest.MonkeyPatch) -> None:
+    kms = table_v3_encrypted._kms_client
+    assert kms is not None
+    unwrap_key = kms.unwrap_key
+    calls = []
+
+    def _spy(wrapped_key: bytes, wrapping_key_id: str) -> bytes:
+        calls.append(wrapping_key_id)
+        return unwrap_key(wrapped_key, wrapping_key_id)
+
+    monkeypatch.setattr(kms, "unwrap_key", _spy)
+
+    for _ in range(3):
+        list(table_v3_encrypted.scan().plan_files())
+    table_v3_encrypted.inspect.manifests()
+    table_v3_encrypted.inspect.all_manifests()
+    assert len(calls) == 1
+
+    table_v3_encrypted.metadata = table_v3_encrypted.metadata.model_copy()
+    list(table_v3_encrypted.scan().plan_files())
+    assert len(calls) == 2
+
+
+def test_scan_encrypted_table(table_v3_encrypted: Table) -> None:
+    """The manifest list was encrypted by iceberg-rust, and lists no 
manifests."""
+    assert list(table_v3_encrypted.scan().plan_files()) == []
+    assert list(table_v3_encrypted.incremental_append_scan().plan_files()) == 
[]
+    assert table_v3_encrypted.scan().to_arrow().num_rows == 0
+
+
+def test_inspect_encrypted_table(table_v3_encrypted: Table) -> None:
+    assert table_v3_encrypted.inspect.manifests().num_rows == 0
+    assert table_v3_encrypted.inspect.all_manifests().num_rows == 0
+    assert table_v3_encrypted.inspect.entries().num_rows == 0
+    assert table_v3_encrypted.inspect.partitions().num_rows == 0
+
+
 def test_add_snapshot_update_fails_without_first_row_id(table_v3: Table) -> 
None:
     new_snapshot = Snapshot(
         snapshot_id=25,
diff --git a/tests/utils/test_manifest.py b/tests/utils/test_manifest.py
index 45e4e894e..accfb07f5 100644
--- a/tests/utils/test_manifest.py
+++ b/tests/utils/test_manifest.py
@@ -16,6 +16,7 @@
 # under the License.
 # pylint: disable=redefined-outer-name,arguments-renamed,fixme
 import importlib
+from dataclasses import replace
 from pathlib import Path
 from tempfile import TemporaryDirectory
 from typing import Any
@@ -26,6 +27,8 @@ import pytest
 import pyiceberg.manifest as manifest_module
 from pyiceberg.avro.codecs import AvroCompressionCodec
 from pyiceberg.avro.file import DEFAULT_SYNC_INTERVAL, AvroFile, AvroOutputFile
+from pyiceberg.encryption.manager import EncryptionManager
+from pyiceberg.encryption.stream import _encrypt_ags1
 from pyiceberg.io import load_file_io
 from pyiceberg.io.pyarrow import PyArrowFileIO
 from pyiceberg.manifest import (
@@ -52,6 +55,7 @@ from pyiceberg.schema import Schema
 from pyiceberg.table.snapshots import Operation, Snapshot, Summary
 from pyiceberg.typedef import Record, TableVersion
 from pyiceberg.types import IntegerType, ListType, LongType, NestedField, 
StructType
+from tests.encryption.memory_kms import MemoryKeyManagementClient
 
 
 @pytest.fixture(autouse=True)
@@ -506,6 +510,44 @@ def test_read_manifest_v2(generated_manifest_file_file_v2: 
str) -> None:
     assert entry.status == ManifestEntryStatus.ADDED
 
 
+def test_read_encrypted_manifest_list(generated_manifest_file_file_v2: str, 
tmp_path: Path) -> None:
+    io = load_file_io()
+    kms = MemoryKeyManagementClient()
+    kms.add_master_key("master-1")
+    encryption_manager = EncryptionManager(kms, "master-1")
+
+    key_metadata = encryption_manager.generate_key_metadata()
+    encrypted = 
_encrypt_ags1(Path(generated_manifest_file_file_v2).read_bytes(), key_metadata)
+    manifest_list = tmp_path / "manifest-list.avro"
+    manifest_list.write_bytes(encrypted)
+    key_id = 
encryption_manager.encrypt_manifest_list_key_metadata(replace(key_metadata, 
file_length=len(encrypted)))
+
+    plaintext = Snapshot(
+        snapshot_id=25,
+        parent_snapshot_id=19,
+        timestamp_ms=1602638573590,
+        manifest_list=generated_manifest_file_file_v2,
+        summary=Summary(Operation.APPEND),
+        schema_id=3,
+    )
+    snapshot = plaintext.model_copy(update={"manifest_list": 
str(manifest_list), "key_id": key_id})
+
+    assert snapshot.manifests(io, encryption_manager) == 
plaintext.manifests(io)
+
+
+def 
test_read_encrypted_manifest_list_without_encryption_manager(generated_manifest_file_file_v2:
 str) -> None:
+    snapshot = Snapshot(
+        snapshot_id=25,
+        timestamp_ms=1602638573590,
+        manifest_list=generated_manifest_file_file_v2,
+        summary=Summary(Operation.APPEND),
+        key_id="key",
+    )
+
+    with pytest.raises(ValueError, match="Snapshot 25 is encrypted, but no 
encryption manager was provided"):
+        snapshot.manifests(load_file_io())
+
+
 def test_read_manifest_cache(generated_manifest_file_file_v2: str) -> None:
     """Test that ManifestFile objects are cached and reused across multiple 
reads.
 

Reply via email to