laskoviymishka commented on code in PR #3236:
URL: https://github.com/apache/iceberg-rust/pull/3236#discussion_r4024352299


##########
crates/iceberg/src/encryption/io.rs:
##########
@@ -138,8 +153,8 @@ impl EncryptedOutputFile {
         )))
     }
 
-    /// Write bytes to file (transparently encrypted).
-    pub async fn write(&self, bs: Bytes) -> Result<()> {
+    /// Write bytes to the file and return its encrypted size.
+    pub async fn write(&self, bs: Bytes) -> Result<FileMetadata> {

Review Comment:
   `write()` returns the ciphertext size, but `self.key_metadata` is never 
updated with it. So the caller has to remember to 
`.clone().with_file_length(file_metadata.size)` before encoding — all four 
current call sites do, but the pre-PR idiom `output.key_metadata().encode()` 
still compiles and silently produces key metadata with `file_length = None`, 
which then fails at read time with a `DataInvalid` that gives no hint the write 
path forgot the length.
   
   That's a sharp edge for a public API. I'd either have `write()` hand back 
the updated `StandardKeyMetadata` alongside the `FileMetadata` (a small 
`WrittenFile { file_metadata, key_metadata }`), or update `self.key_metadata` 
in place so `key_metadata()` reflects the length after the write. Either 
removes the boilerplate and the footgun.
   
   wdyt?



##########
crates/iceberg/src/encryption/io.rs:
##########
@@ -74,14 +73,30 @@ impl EncryptedInputFile {
 
     /// Creates a reader that transparently decrypts on each read.
     pub async fn reader(&self) -> Result<Box<dyn FileRead>> {
-        let raw_meta = self.inner.metadata().await?;
+        let encrypted_length = self.encrypted_length()?;
         let raw_reader = self.inner.reader().await?;
         let cipher = build_cipher(&self.key_metadata)?;
         let aad_prefix: Box<[u8]> = 
self.key_metadata.aad_prefix().unwrap_or_default().into();
-        let decrypting = AesGcmFileRead::new(raw_reader, cipher, aad_prefix, 
raw_meta.size)?;
+        let decrypting = AesGcmFileRead::new(raw_reader, cipher, aad_prefix, 
encrypted_length)?;
         Ok(Box::new(decrypting))
     }
 
+    fn encrypted_length(&self) -> Result<u64> {

Review Comment:
   This makes `file_length` mandatory on every read path — `metadata()`, 
`reader()`, and `read()` all route through here now, and there's no stat 
fallback. So every encrypted manifest, manifest list, and puffin file the 
earlier PRs in this series already wrote (without `file_length` in the key 
metadata) becomes permanently unreadable by iceberg-rust, not just by Java.
   
   I get that trusting the declared length is the whole point of truncation 
proofing, so I don't think we can fall back silently. But I'd want us to pick 
one explicitly: either fall back to `self.inner.metadata()` when `file_length` 
is `None` and warn that truncation protection is degraded (matches PyIceberg, 
keeps old tables readable), or accept the hard break and call it out 
prominently in the changelog as a one-time migration for the encryption feature.
   
   If the feature is explicitly no-stability-yet, the second is fine — I just 
don't want it to be a silent break. wdyt?



##########
crates/iceberg/src/spec/manifest/writer.rs:
##########
@@ -508,14 +508,22 @@ impl ManifestWriter {
         }
 
         let content = avro_writer.into_inner()?;
-        let length = content.len();
         let mut writer = self.writer_future.await?;
         writer.write(Bytes::from(content)).await?;
-        writer.close().await?;
+        let file_metadata = writer.close().await?;
+        let key_metadata = self
+            .key_metadata
+            .map(|metadata| {
+                metadata
+                    .with_file_length(file_metadata.size)
+                    .encode()
+                    .map(|bytes| bytes.into_vec())
+            })
+            .transpose()?;
 
         Ok(ManifestFile {
             manifest_path: self.location,
-            manifest_length: length as i64,
+            manifest_length: file_metadata.size.try_into()?,

Review Comment:
   Good catch switching this to the on-disk size — for encrypted manifests the 
old `content.len()` was the plaintext Avro length, which is actually a spec 
violation (field 501 is the on-disk file length), so this is more correct, not 
just different.
   
   Since it's a deliberate behavior change that's invisible for unencrypted 
files and only shows up encrypted, I'd add a one-line comment noting the value 
is now the ciphertext/on-disk length, plus a small regression test for an 
unencrypted manifest asserting `manifest_length == plaintext_avro_len` so 
nobody "fixes" it back later.



##########
crates/iceberg/src/encryption/io.rs:
##########
@@ -215,8 +236,134 @@ mod tests {
             "encrypted file should be larger than plaintext (header + nonce + 
tag)"
         );
 
-        let input = EncryptedInputFile::new(fileio.new_input(path).unwrap(), 
key_metadata());
+        // A missing path proves the size comes from the key metadata rather 
than a stat call.
+        let input = EncryptedInputFile::new(
+            fileio.new_input("memory:///does-not-exist").unwrap(),
+            key_metadata().with_file_length(file_metadata.size),
+        );
         let meta = input.metadata().await.unwrap();
         assert_eq!(meta.size, plaintext.len() as u64);
     }
+
+    #[tokio::test]
+    async fn test_missing_file_length_is_rejected() {
+        let fileio = FileIO::new_with_memory();
+        let path = "memory:///test/missing_length.bin";
+        let output = 
EncryptedOutputFile::new(fileio.new_output(path).unwrap(), key_metadata());
+        output.write(Bytes::from_static(b"data")).await.unwrap();
+        let input = EncryptedInputFile::new(fileio.new_input(path).unwrap(), 
key_metadata());
+
+        for err in [
+            input.metadata().await.err().unwrap(),
+            input.reader().await.err().unwrap(),
+            input.read().await.unwrap_err(),
+        ] {
+            assert_eq!(err.kind(), ErrorKind::DataInvalid);
+            assert!(
+                err.to_string()
+                    .contains("missing the encrypted file length")
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_invalid_file_length_is_rejected() {
+        let fileio = FileIO::new_with_memory();
+        for length in [
+            0,
+            u64::from(GCM_STREAM_HEADER_LENGTH),
+            u64::from(MIN_STREAM_LENGTH - 1),
+        ] {
+            let input = EncryptedInputFile::new(
+                fileio
+                    .new_input("memory:///test/invalid_length.bin")
+                    .unwrap(),
+                key_metadata().with_file_length(length),
+            );
+            assert_eq!(
+                input.metadata().await.err().unwrap().kind(),
+                ErrorKind::DataInvalid
+            );
+            assert_eq!(
+                input.reader().await.err().unwrap().kind(),
+                ErrorKind::DataInvalid
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_truncated_file_is_rejected() {

Review Comment:
   These truncation tests all cover the file-shorter-than-declared case with 
correct key metadata. The other half of the tamper check — key metadata 
claiming a *larger* `file_length` than what's actually on disk — isn't 
exercised anywhere.
   
   Since catching an inflated declared length is exactly what the new 
short-read check in `AesGcmFileRead::read` is for, I'd add a case with 
`with_file_length(file_metadata.size + CIPHER_BLOCK_SIZE)` asserting a 
`DataInvalid`. That locks the contract in both directions.



##########
crates/iceberg/src/io/file_io.rs:
##########
@@ -375,10 +375,10 @@ pub trait FileWrite: Send + Unpin + 'static {
     /// TODO: we can support writing non-contiguous bytes in the future.
     async fn write(&mut self, bs: Bytes) -> Result<()>;
 
-    /// Close file.
+    /// Close the file and return its stored size.
     ///
     /// Calling close on closed file will generate an error.
-    async fn close(&mut self) -> Result<()>;
+    async fn close(&mut self) -> Result<FileMetadata>;

Review Comment:
   Flipping `close()` from `Result<()>` to `Result<FileMetadata>` on the public 
`FileWrite` trait (plus `EncryptedOutputFile::write`, 
`ManifestListWriter::close`, `PuffinWriter::close`) is a breaking change for 
any downstream implementor or `Ok(())` matcher. That's fine for this series — 
I'd just make sure the changelog lists all four signature changes so it isn't a 
surprise.
   
   While we're here, "stored size" is ambiguous for the encrypting wrappers, 
where it's the ciphertext size (larger than what the caller wrote). Worth one 
line saying that explicitly, since that distinction is the whole point of the 
PR.



##########
crates/iceberg/src/transaction/snapshot.rs:
##########
@@ -492,7 +492,20 @@ impl<'a> SnapshotProducer<'a> {
 
         manifest_list_writer.add_manifests(new_manifests.into_iter())?;
         let writer_next_row_id = manifest_list_writer.next_row_id();
-        manifest_list_writer.close().await?;
+        let file_metadata = manifest_list_writer.close().await?;
+        let encryption_key_id = if let Some(key_metadata) = key_metadata {
+            Some(
+                self.table
+                    .encryption_manager()
+                    .expect("Encryption manager must be present when key 
metadata exists")

Review Comment:
   This `.expect()` panics if the invariant ever breaks, and it's only sound 
because `key_metadata.is_some()` implies the manager is present — an invariant 
enforced by construction up in the match, not by the types. A future reorder 
here turns into an unrecoverable panic in library code.
   
   Since we already matched on `encryption_manager()` above, I'd capture the 
`Arc` in that first arm and carry it down alongside `key_metadata`, so there's 
no second lookup and no `expect`. wdyt?



##########
crates/iceberg/src/encryption/stream.rs:
##########
@@ -1002,8 +1036,10 @@ mod tests {
             Ok(())
         }
 
-        async fn close(&mut self) -> Result<()> {
-            Ok(())
+        async fn close(&mut self) -> Result<FileMetadata> {

Review Comment:
   `SharedMemoryWrite::close` now returns the buffer length, but the 
`write_through_ags1` helper still does `writer.close().await.unwrap()` and 
drops it, so none of the ~dozen tests using it assert that 
`AesGcmFileWrite::close`'s reported size matches the actual ciphertext length. 
That's the primary AGS1 close path, and it's the value the whole PR hangs on.
   
   I'd have the helper return `(Vec<u8>, FileMetadata)` and assert 
`metadata.size == encrypted.len()` in at least one caller. Cheap, and it pins 
the streaming path directly.



##########
crates/iceberg/src/encryption/stream.rs:
##########
@@ -990,8 +1024,8 @@ mod tests {
             Ok(())
         }
 
-        async fn close(&mut self) -> Result<()> {
-            Ok(())
+        async fn close(&mut self) -> Result<FileMetadata> {
+            unreachable!()

Review Comment:
   `unreachable!()` compiles here (the never type coerces to `FileMetadata`), 
but it's only correct as long as `AesGcmFileWrite::close` returns early from 
the poisoned-state guard before it ever reaches `self.inner.close()`. Nothing 
asserts that ordering, and if it changes this panics with no message (swallowed 
without `--nocapture`).
   
   I'd return a real `Err(Error::new(ErrorKind::Unexpected, 
"FailingFileWrite::close called unexpectedly"))` instead — same effect for the 
test, but it fails loudly and stays a proper `FileWrite` impl.



##########
crates/iceberg/src/puffin/writer.rs:
##########
@@ -117,12 +117,11 @@ impl PuffinWriter {
         Ok(())
     }
 
-    /// Finalizes the Puffin file
-    pub async fn close(mut self) -> Result<()> {
+    /// Finalizes the Puffin file and returns its stored size.
+    pub async fn close(mut self) -> Result<io::FileMetadata> {

Review Comment:
   Small readability thing: `puffin::metadata::FileMetadata` and 
`io::FileMetadata` are both in scope here, and the `io::` prefix is the only 
thing keeping them apart. I'd alias the import — `use crate::io::FileMetadata 
as IoFileMetadata;` — so a future edit can't quietly grab the wrong one.



##########
crates/storage/opendal/src/lib.rs:
##########
@@ -635,21 +635,56 @@ impl FileRead for OpenDalReader {
 }
 
 /// Wrapper around `opendal::Writer` that implements `FileWrite`.
-pub(crate) struct OpenDalWriter(pub(crate) opendal::Writer);
+pub(crate) struct OpenDalWriter {
+    inner: opendal::Writer,
+    bytes_written: u64,
+}
+
+impl OpenDalWriter {
+    pub(crate) fn new(inner: opendal::Writer) -> Self {
+        Self {
+            inner,
+            bytes_written: 0,
+        }
+    }
+}
 
 #[async_trait]
 impl FileWrite for OpenDalWriter {
     async fn write(&mut self, bs: Bytes) -> Result<()> {
-        Ok(opendal::Writer::write(&mut self.0, bs)
+        let len = bs.len() as u64;
+        opendal::Writer::write(&mut self.inner, bs)
             .await
-            .map_err(from_opendal_error)?)
+            .map_err(from_opendal_error)?;
+        self.bytes_written += len;
+        Ok(())
     }
 
-    async fn close(&mut self) -> Result<()> {
-        let _ = opendal::Writer::close(&mut self.0)
+    async fn close(&mut self) -> Result<FileMetadata> {
+        let metadata = opendal::Writer::close(&mut self.inner)
             .await
             .map_err(from_opendal_error)?;
-        Ok(())
+
+        // `Metadata::content_length()` silently returns 0 when the service 
did not report a
+        // size, and most object stores don't: S3 only populates it from the 
`x-amz-object-size`
+        // response header, which general-purpose buckets never send. A bogus 
0 here would be
+        // written into `manifest_length` and into the AGS1 `file_length` used 
for truncation
+        // protection, making the file permanently unreadable, so trust our 
own byte count and
+        // only use the service value to detect a genuine mismatch.
+        let reported_size = metadata.content_length();
+        if reported_size != 0 && reported_size != self.bytes_written {

Review Comment:
   The comment above this is great — it explains exactly why we trust 
`bytes_written` over `content_length()`. Given that reasoning, though, this 
mismatch branch never actually fires on the common production path: 
S3/GCS/Azure return 0, so `reported_size != 0` is false and we skip it. It only 
triggers for in-memory operators, where the two are equal by construction.
   
   So it reads like a cross-validation guarantee but protects nothing where 
it'd matter. I'd either demote it to a `debug_assert!` or reword the intent to 
"only validates when the store reports a size." Not blocking — I just don't 
want a future reader to trust it as a real integrity check. wdyt?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to