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

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 0fc66726e feat(encryption): add StandardEncryptionManager for generic 
file envelope encryption (#1620)
0fc66726e is described below

commit 0fc66726eab8eeb424449be48983ee6614c729a9
Author: Hectar <[email protected]>
AuthorDate: Wed Oct 14 06:23:50 2026 -0700

    feat(encryption): add StandardEncryptionManager for generic file envelope 
encryption (#1620)
    
    * feat(encryption): add StandardEncryptionManager for generic file envelope 
encryption.
    
    Signed-off-by: hectar-glitches <[email protected]>
    
    * fix(encryption): validate block size and decoded key metadata in 
StandardEncryptionManager.
    
    Signed-off-by: hectar-glitches <[email protected]>
    
    * fix(encryption): harden standard manager I/O
    
    Signed-off-by: hectar-glitches <[email protected]>
    
    * Address blockers
    
    * fix major concerns with filewriter and standardinputfile
    
    * address the major and minor issues, rename the identifier from standard 
to go envelope
    
    * address memory limit issue and nits
    
    * fix(encryption): add nonce-uniqueness test, bound aad-prefix length
    
    * chore(encryption): rebase onto main, fix modernize lint
    
    * feat(encryption): make StandardEncryptionManager Java-compatible
    
    Read and write the same bytes as Java's StandardEncryptionManager and
    iceberg-rust, so files can be exchanged between implementations.
    
    - Use a fixed 1 MiB plaintext block size. Remove WithBlockSize,
      GoEnvelopeMaxBlockSize and ErrInvalidBlockSize. The reader requires
      the stream header to declare 1 MiB.
    - Encode key metadata as StandardKeyMetadata: a version byte followed by
      an Avro record {encryption_key, aad_prefix, file_length}. The DEK is
      generated locally and stored raw; there is no KMS call per file. The
      reader derives the plaintext length from file_length.
    - Write block 0 for an empty file, giving the 36-byte layout Java and
      iceberg-rust use, and accept it on read.
    - Default the data key length to 16 bytes and rename the option to
      WithStandardDataKeyLength.
    - Rename GoEnvelopeEncryptionManager back to StandardEncryptionManager.
    - Document that ErrOutputFileClosed is only returned after a successful
      Close.
    
    Tests decode the writer's output with a stdlib-only AGS1 decoder, read a
    stream built the same way, and check a hand-encoded key metadata vector.
    Block-size tests now use multi-MiB payloads.
    
    * fixes to PR description, major java issue and minor issues
    
    ---------
    
    Signed-off-by: hectar-glitches <[email protected]>
    Co-authored-by: Matt Topol <[email protected]>
---
 encryption/standard_manager.go      | 888 +++++++++++++++++++++++++++++++++
 encryption/standard_manager_test.go | 947 ++++++++++++++++++++++++++++++++++++
 2 files changed, 1835 insertions(+)

diff --git a/encryption/standard_manager.go b/encryption/standard_manager.go
new file mode 100644
index 000000000..fdac33de6
--- /dev/null
+++ b/encryption/standard_manager.go
@@ -0,0 +1,888 @@
+// 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.
+
+package encryption
+
+import (
+       "context"
+       "crypto/aes"
+       "crypto/cipher"
+       "crypto/rand"
+       "encoding/binary"
+       "errors"
+       "fmt"
+       "io"
+       "io/fs"
+       "math"
+       "slices"
+       "sync"
+
+       icebergio "github.com/apache/iceberg-go/io"
+)
+
+// StandardDefaultDataKeyLength is the default length, in bytes, of the
+// per-file data encryption key (DEK). It is 128-bit, matching the default of
+// the encryption.data-key-length table property in the Java and Rust
+// implementations.
+const StandardDefaultDataKeyLength = 16
+
+// Sentinel errors returned by [StandardEncryptionManager].
+var (
+       // ErrKeyIDRequired is returned by
+       // [StandardEncryptionManager.NewEncryptedOutputFile] when keyID is 
empty.
+       // StandardEncryptionManager always encrypts, so it requires the table's
+       // encryption key ID; use [PlaintextEncryptionManager] for unencrypted
+       // tables instead of passing an empty keyID here.
+       ErrKeyIDRequired = errors.New("encryption: StandardEncryptionManager 
requires a non-empty keyID")
+
+       // ErrKeyMetadataRequired is returned by
+       // [StandardEncryptionManager.NewDecryptedInputFile] and
+       // [StandardEncryptionManager.NewDecryptedInputFileWithLength] when
+       // keyMetadata is empty. StandardEncryptionManager always decrypts, so 
it requires the
+       // per-file key metadata produced by
+       // [StandardEncryptionManager.NewEncryptedOutputFile].
+       ErrKeyMetadataRequired = errors.New("encryption: 
StandardEncryptionManager requires non-empty key metadata")
+
+       // ErrUnsupportedKeyMetadataVersion is returned when key metadata starts
+       // with a version byte other than the one this package understands.
+       ErrUnsupportedKeyMetadataVersion = errors.New("encryption: unsupported 
key metadata version")
+
+       // ErrInvalidStreamHeader is returned when the AES GCM Stream header 
(the
+       // "AGS1" magic and little-endian plain block length at the start of an
+       // encrypted file, per the Iceberg AES GCM Stream spec) is missing,
+       // truncated, or declares a block length other than the fixed 1 MiB.
+       ErrInvalidStreamHeader = errors.New("encryption: invalid AES GCM Stream 
header")
+
+       // ErrInvalidKeyMetadata is returned by
+       // [StandardEncryptionManager.NewDecryptedInputFile] and
+       // [StandardEncryptionManager.NewDecryptedInputFileWithLength] when key
+       // metadata is not a well-formed StandardKeyMetadata record or fails 
basic
+       // sanity checks (e.g. a truncated record, a missing or wrong-sized AAD
+       // prefix, or a missing or impossible encrypted length). Key metadata is
+       // untrusted input on a crypto read path, so it is validated rather than
+       // trusted blindly.
+       ErrInvalidKeyMetadata = errors.New("encryption: invalid key metadata")
+
+       // ErrOutputFileClosed is returned by [standardOutputFile.Write] when
+       // called after a successful Close. It wraps [fs.ErrClosed] so callers 
can
+       // test with errors.Is(err, fs.ErrClosed). A Write after a failed flush 
or
+       // a failed Close returns that original error instead.
+       ErrOutputFileClosed = fmt.Errorf("encryption: write to closed 
StandardEncryptionManager output file: %w", fs.ErrClosed)
+
+       // ErrBlockTruncated is returned by [standardInputFile.ReadAt] when the
+       // underlying storage returns fewer ciphertext bytes for a block than 
its
+       // recorded length requires, indicating the file was truncated at rest.
+       // This is distinct from [ErrCiphertextTooShort], which 
[KeyManagementClient]
+       // implementations use for a too-short wrapped key or KMS-encrypted
+       // payload; keeping them separate lets a caller tell a malformed KMS 
blob
+       // apart from a short block read.
+       ErrBlockTruncated = errors.New("encryption: block truncated: read fewer 
ciphertext bytes than expected")
+)
+
+// Constants describing the Iceberg AES GCM Stream ("AGS1") wire format used
+// for the ciphertext produced by [StandardEncryptionManager]. See
+// https://iceberg.apache.org/gcm-stream-spec/ for the full specification.
+const (
+       // gcmStreamMagic identifies an AES GCM Stream version 1 file.
+       gcmStreamMagic = "AGS1"
+
+       // gcmStreamHeaderLength is the length, in bytes, of the magic plus the
+       // 4-byte little-endian plain block length written at the start of every
+       // file.
+       gcmStreamHeaderLength = 8
+
+       // gcmStreamPlainBlockSize is the plaintext block size. It is fixed:
+       // Java's Ciphers.PLAIN_BLOCK_SIZE and iceberg-rust's PLAIN_BLOCK_SIZE 
are
+       // both 1 MiB, and Java's AesGcmInputStream rejects any other value in 
the
+       // header, so a stream with a different block size is unreadable there.
+       gcmStreamPlainBlockSize = 1024 * 1024
+
+       // gcmStreamNonceLength is the length, in bytes, of the random AES-GCM
+       // nonce stored at the start of every cipher block.
+       gcmStreamNonceLength = 12
+
+       // gcmStreamTagLength is the length, in bytes, of the AES-GCM
+       // authentication tag appended to every cipher block's ciphertext.
+       gcmStreamTagLength = 16
+
+       // gcmStreamBlockOverhead is the number of ciphertext bytes added to
+       // each block beyond its plaintext length (nonce + tag).
+       gcmStreamBlockOverhead = gcmStreamNonceLength + gcmStreamTagLength
+
+       // gcmStreamCipherBlockSize is the on-disk length of a full block.
+       gcmStreamCipherBlockSize = gcmStreamPlainBlockSize + 
gcmStreamBlockOverhead
+
+       // gcmStreamMinLength is the length of the smallest valid stream: the
+       // header followed by one block holding no plaintext. Java and 
iceberg-rust
+       // always write block 0, even for an empty file, and iceberg-rust 
rejects
+       // anything shorter.
+       gcmStreamMinLength = gcmStreamHeaderLength + gcmStreamBlockOverhead
+
+       // gcmStreamAADPrefixLength is the length, in bytes, of the random
+       // per-file AAD prefix. It matches Java's ENCRYPTION_AAD_LENGTH_DEFAULT.
+       gcmStreamAADPrefixLength = 16
+)
+
+// standardKeyMetadataVersion is the leading version byte of Iceberg's
+// StandardKeyMetadata encoding.
+const standardKeyMetadataVersion = 1
+
+// standardKeyMetadata is the decoded form of Iceberg's StandardKeyMetadata:
+// a version byte followed by an Avro binary record
+//
+//     {encryption_key: bytes, aad_prefix: ["null", bytes], file_length: 
["null", long]}
+//
+// This is the key_metadata encoding Java's StandardEncryptionManager,
+// iceberg-rust and pyiceberg read and write. It holds the raw DEK, so it is a
+// secret and must be protected by whatever stores it.
+type standardKeyMetadata struct {
+       encryptionKey []byte
+
+       // aadPrefix is combined with each block's little-endian index to form
+       // the AES GCM Stream additional authenticated data, binding every
+       // ciphertext block to this file and to its position so that blocks
+       // cannot be silently reordered, replayed from another file, or spliced
+       // in from elsewhere in the same file. It is not secret. It is nil when
+       // the record omits it.
+       aadPrefix []byte
+
+       // fileLength is the length of the encrypted stream, header included.
+       // The reader derives the plaintext length from it, per the AES GCM
+       // Stream spec's "File length" note. hasFileLength is false when the
+       // record omits it.
+       fileLength    int64
+       hasFileLength bool
+}
+
+// encodeStandardKeyMetadata encodes the version byte and Avro record for a
+// file, with both optional fields present.
+func encodeStandardKeyMetadata(encryptionKey, aadPrefix []byte, fileLength 
int64) EncryptionKeyMetadata {
+       b := []byte{standardKeyMetadataVersion}
+       b = binary.AppendVarint(b, int64(len(encryptionKey)))
+       b = append(b, encryptionKey...)
+       b = binary.AppendVarint(b, 1) // union branch 1 (bytes): aad_prefix is 
present
+       b = binary.AppendVarint(b, int64(len(aadPrefix)))
+       b = append(b, aadPrefix...)
+       b = binary.AppendVarint(b, 1) // union branch 1 (long): file_length is 
present
+       b = binary.AppendVarint(b, fileLength)
+
+       return b
+}
+
+// decodeStandardKeyMetadata decodes the version byte and Avro record written
+// by [encodeStandardKeyMetadata] or by Java's StandardKeyMetadata.buffer().
+func decodeStandardKeyMetadata(b []byte) (standardKeyMetadata, error) {
+       if b[0] != standardKeyMetadataVersion {
+               return standardKeyMetadata{}, fmt.Errorf("%w: %d", 
ErrUnsupportedKeyMetadataVersion, b[0])
+       }
+
+       r := avroReader{buf: b[1:]}
+       var k standardKeyMetadata
+       k.encryptionKey = r.bytes()
+       if r.present() {
+               k.aadPrefix = r.bytes()
+       }
+       if r.present() {
+               k.fileLength = r.long()
+               k.hasFileLength = true
+       }
+       if r.err == nil && len(r.buf) != 0 {
+               r.err = fmt.Errorf("%d unexpected trailing bytes", len(r.buf))
+       }
+       if r.err != nil {
+               return standardKeyMetadata{}, fmt.Errorf("%w: %w", 
ErrInvalidKeyMetadata, r.err)
+       }
+
+       return k, nil
+}
+
+// avroReader decodes the few Avro binary primitives StandardKeyMetadata uses,
+// remembering the first error. Avro longs are zig-zag varints, which is what
+// encoding/binary's Varint implements.
+type avroReader struct {
+       buf []byte
+       err error
+}
+
+func (r *avroReader) long() int64 {
+       if r.err != nil {
+               return 0
+       }
+       v, n := binary.Varint(r.buf)
+       if n <= 0 {
+               r.err = errors.New("truncated or oversized varint")
+
+               return 0
+       }
+       r.buf = r.buf[n:]
+
+       return v
+}
+
+func (r *avroReader) bytes() []byte {
+       n := r.long()
+       if r.err != nil {
+               return nil
+       }
+       if n < 0 || n > int64(len(r.buf)) {
+               r.err = fmt.Errorf("bytes length %d out of range (%d bytes 
remain)", n, len(r.buf))
+
+               return nil
+       }
+       out := slices.Clone(r.buf[:n])
+       r.buf = r.buf[n:]
+
+       return out
+}
+
+// present reads the branch index of a ["null", T] union and reports whether
+// the value is present (branch 1) rather than null (branch 0).
+func (r *avroReader) present() bool {
+       branch := r.long()
+       if r.err != nil {
+               return false
+       }
+       if branch != 0 && branch != 1 {
+               r.err = fmt.Errorf("unexpected union branch %d", branch)
+
+               return false
+       }
+
+       return branch == 1
+}
+
+// plaintextLengthFromStreamLength derives the plaintext length from the
+// length of the encrypted stream, the way Java's
+// AesGcmInputStream.calculatePlaintextLength does. It accepts the 36-byte
+// form of an empty file (header plus one empty block) and rejects lengths no
+// writer could have produced.
+func plaintextLengthFromStreamLength(streamLength int64) (int64, error) {
+       if streamLength < gcmStreamMinLength {
+               return 0, fmt.Errorf("%w: file-length %d is shorter than the 
%d-byte minimum stream", ErrInvalidKeyMetadata, streamLength, 
gcmStreamMinLength)
+       }
+
+       body := streamLength - gcmStreamHeaderLength
+       plainLength := body / gcmStreamCipherBlockSize * gcmStreamPlainBlockSize
+       if lastBlock := body % gcmStreamCipherBlockSize; lastBlock != 0 {
+               if lastBlock < gcmStreamBlockOverhead {
+                       return 0, fmt.Errorf("%w: file-length %d ends in a 
%d-byte block, shorter than its %d-byte nonce and tag", ErrInvalidKeyMetadata, 
streamLength, lastBlock, gcmStreamBlockOverhead)
+               }
+               plainLength += lastBlock - gcmStreamBlockOverhead
+       }
+
+       return plainLength, nil
+}
+
+// StandardEncryptionManager is a generic, format-agnostic [EncryptionManager]
+// for arbitrary files (e.g. manifests, manifest lists, Puffin statistics). It
+// reads and writes the same bytes as Java's StandardEncryptionManager,
+// iceberg-rust and pyiceberg: an Iceberg AES GCM Stream ("AGS1") with fixed
+// 1 MiB plaintext blocks, and key metadata in Iceberg's StandardKeyMetadata
+// encoding.
+//
+// Each file is encrypted under a fresh random data encryption key (DEK),
+// generated locally with no KMS call. Each block carries its own random nonce
+// and is authenticated with an AAD that binds it to the file and to its
+// position. This bounds memory usage and supports random access
+// (Seek/ReadAt) on the decrypted file without buffering or decrypting more
+// than the requested blocks.
+//
+// The key metadata returned by Close contains the raw DEK, so it is a secret:
+// persist it only where something protects it, such as a manifest or manifest
+// list that is itself encrypted under a key encryption key.
+//
+// StandardEncryptionManager always encrypts and always decrypts: it fails
+// closed, returning [ErrKeyIDRequired] or [ErrKeyMetadataRequired] rather
+// than silently falling back to plaintext. Use [PlaintextEncryptionManager]
+// for tables or files that are not encrypted.
+type StandardEncryptionManager struct {
+       dataKeyLength int
+}
+
+var _ EncryptionManager = (*StandardEncryptionManager)(nil)
+
+// StandardManagerOption configures a [StandardEncryptionManager] created by
+// [NewStandardEncryptionManager].
+type StandardManagerOption func(*StandardEncryptionManager)
+
+// WithStandardDataKeyLength sets the length, in bytes, of the per-file data
+// encryption key. It corresponds to the encryption.data-key-length table
+// property. Valid AES key lengths are 16, 24, or 32 bytes; the default is
+// [StandardDefaultDataKeyLength].
+func WithStandardDataKeyLength(length int) StandardManagerOption {
+       return func(m *StandardEncryptionManager) { m.dataKeyLength = length }
+}
+
+// NewStandardEncryptionManager creates a [StandardEncryptionManager].
+func NewStandardEncryptionManager(opts ...StandardManagerOption) 
*StandardEncryptionManager {
+       m := &StandardEncryptionManager{dataKeyLength: 
StandardDefaultDataKeyLength}
+       for _, opt := range opts {
+               opt(m)
+       }
+
+       return m
+}
+
+// NewEncryptedOutputFile creates a new AES-GCM block-encrypted output file.
+// keyID is the table's encryption key ID and must be non-empty; otherwise
+// [ErrKeyIDRequired] is returned. keyID is only checked: the file's key
+// metadata holds the raw DEK.
+func (m *StandardEncryptionManager) NewEncryptedOutputFile(_ context.Context, 
writer icebergio.FileWriter, keyID string) (EncryptedOutputFile, error) {
+       if keyID == "" {
+               return nil, ErrKeyIDRequired
+       }
+       switch m.dataKeyLength {
+       case 16, 24, 32:
+       default:
+               return nil, fmt.Errorf("%w: DEK length must be 16, 24, or 32 
bytes; got %d", ErrInvalidKeyLength, m.dataKeyLength)
+       }
+
+       // A fresh DEK per file keeps the number of random-nonce encryptions 
under
+       // any one key within the 2^32 limit NIST SP 800-38D sets for random
+       // 96-bit nonces: a file holds at most 2^32-1 blocks.
+       dek := make([]byte, m.dataKeyLength)
+       if _, err := io.ReadFull(rand.Reader, dek); err != nil {
+               return nil, fmt.Errorf("encryption: failed to generate DEK: 
%w", err)
+       }
+
+       aead, err := newStandardAEAD(dek)
+       if err != nil {
+               return nil, err
+       }
+
+       aadPrefix := make([]byte, gcmStreamAADPrefixLength)
+       if _, err := io.ReadFull(rand.Reader, aadPrefix); err != nil {
+               return nil, fmt.Errorf("encryption: failed to generate AAD 
prefix: %w", err)
+       }
+
+       // Write the AES GCM Stream header (magic + little-endian block length)
+       // up front, before any ciphertext blocks, per the format spec.
+       header := make([]byte, gcmStreamHeaderLength)
+       copy(header, gcmStreamMagic)
+       binary.LittleEndian.PutUint32(header[len(gcmStreamMagic):], 
gcmStreamPlainBlockSize)
+       if _, err := writer.Write(header); err != nil {
+               _ = writer.Close()
+
+               return nil, fmt.Errorf("encryption: failed to write stream 
header: %w", err)
+       }
+
+       return &standardOutputFile{
+               FileWriter:      writer,
+               aead:            aead,
+               dek:             dek,
+               aadPrefix:       aadPrefix,
+               buf:             make([]byte, 0, gcmStreamPlainBlockSize),
+               sealBuf:         make([]byte, 0, gcmStreamCipherBlockSize),
+               encryptedLength: gcmStreamHeaderLength,
+       }, nil
+}
+
+// NewDecryptedInputFile wraps file for transparent block-level AES-GCM
+// decryption. keyMetadata must be a non-empty StandardKeyMetadata record, as
+// produced by [StandardEncryptionManager.NewEncryptedOutputFile] or another
+// Iceberg implementation; otherwise [ErrKeyMetadataRequired] is returned. The
+// record must carry a 16-byte AAD prefix and a file length.
+//
+// The plaintext length is derived from that file length. For key metadata
+// without one, use 
[StandardEncryptionManager.NewDecryptedInputFileWithLength].
+func (m *StandardEncryptionManager) NewDecryptedInputFile(ctx context.Context, 
file icebergio.File, keyMetadata EncryptionKeyMetadata) (EncryptedInputFile, 
error) {
+       return m.newDecryptedInputFile(ctx, file, -1, keyMetadata)
+}
+
+// NewDecryptedInputFileWithLength is like
+// [StandardEncryptionManager.NewDecryptedInputFile], but accepts key metadata
+// without a file length, as Java writes for data files and manifests.
+// encryptedLength is the length of the encrypted file, header included, as
+// recorded in table metadata (DataFile.FileSizeBytes, ManifestFile.Length). A
+// file length in the key metadata takes precedence.
+func (m *StandardEncryptionManager) NewDecryptedInputFileWithLength(ctx 
context.Context, file icebergio.File, encryptedLength int64, keyMetadata 
EncryptionKeyMetadata) (EncryptedInputFile, error) {
+       if encryptedLength < 0 {
+               return nil, fmt.Errorf("%w: encrypted length must be 
non-negative, got %d", ErrInvalidKeyMetadata, encryptedLength)
+       }
+
+       return m.newDecryptedInputFile(ctx, file, encryptedLength, keyMetadata)
+}
+
+// newDecryptedInputFile implements both read entry points. callerLength is the
+// caller-supplied encrypted length, or -1 when there is none.
+func (m *StandardEncryptionManager) newDecryptedInputFile(_ context.Context, 
file icebergio.File, callerLength int64, keyMetadata EncryptionKeyMetadata) 
(EncryptedInputFile, error) {
+       if len(keyMetadata) == 0 {
+               return nil, ErrKeyMetadataRequired
+       }
+
+       meta, err := decodeStandardKeyMetadata(keyMetadata)
+       if err != nil {
+               return nil, err
+       }
+       // Java, iceberg-rust and this writer all emit 16-byte prefixes. 
Bounding
+       // it also keeps the per-block AAD allocation in readBlock small.
+       if len(meta.aadPrefix) != gcmStreamAADPrefixLength {
+               return nil, fmt.Errorf("%w: aad-prefix must be exactly %d 
bytes, got %d", ErrInvalidKeyMetadata, gcmStreamAADPrefixLength, 
len(meta.aadPrefix))
+       }
+       streamLength := callerLength
+       switch {
+       case meta.hasFileLength:
+               streamLength = meta.fileLength
+       case callerLength < 0:
+               return nil, fmt.Errorf("%w: file-length is required when no 
encrypted length is supplied", ErrInvalidKeyMetadata)
+       }
+       plaintextLength, err := plaintextLengthFromStreamLength(streamLength)
+       if err != nil {
+               return nil, err
+       }
+
+       aead, err := newStandardAEAD(meta.encryptionKey)
+       if err != nil {
+               return nil, err
+       }
+
+       if err := validateGCMStreamHeader(file); err != nil {
+               return nil, err
+       }
+
+       return &standardInputFile{
+               underlying:      file,
+               aead:            aead,
+               aadPrefix:       meta.aadPrefix,
+               plaintextLength: plaintextLength,
+               keyMetadata:     keyMetadata,
+       }, nil
+}
+
+func newStandardAEAD(key []byte) (cipher.AEAD, error) {
+       block, err := aes.NewCipher(key)
+       if err != nil {
+               return nil, fmt.Errorf("%w: %w", ErrInvalidKeyLength, err)
+       }
+       gcm, err := cipher.NewGCM(block)
+       if err != nil {
+               return nil, fmt.Errorf("encryption: failed to create GCM: %w", 
err)
+       }
+
+       return gcm, nil
+}
+
+// validateGCMStreamHeader reads the AES GCM Stream header at the start of
+// file and checks that it is the "AGS1" magic followed by the fixed 1 MiB
+// plain block length. Reads use [gcmStreamPlainBlockSize] as the block size.
+func validateGCMStreamHeader(file icebergio.File) error {
+       header := make([]byte, gcmStreamHeaderLength)
+       n, err := file.ReadAt(header, 0)
+       if err != nil && !errors.Is(err, io.EOF) {
+               return fmt.Errorf("encryption: failed to read stream header: 
%w", err)
+       }
+       if n != gcmStreamHeaderLength {
+               return fmt.Errorf("%w: expected %d header bytes, got %d", 
ErrInvalidStreamHeader, gcmStreamHeaderLength, n)
+       }
+       if string(header[:len(gcmStreamMagic)]) != gcmStreamMagic {
+               return fmt.Errorf("%w: missing %q magic", 
ErrInvalidStreamHeader, gcmStreamMagic)
+       }
+
+       if blockSize := 
binary.LittleEndian.Uint32(header[len(gcmStreamMagic):]); blockSize != 
gcmStreamPlainBlockSize {
+               return fmt.Errorf("%w: block length %d, want %d", 
ErrInvalidStreamHeader, blockSize, gcmStreamPlainBlockSize)
+       }
+
+       return nil
+}
+
+// gcmStreamBlockAAD derives the AES-GCM additional authenticated data for
+// blockIndex: the per-file AAD prefix followed by the 4-byte little-endian
+// block index, per the Iceberg AES GCM Stream spec. GCM authenticates the AAD
+// along with the ciphertext, so a block only verifies at its own index in its
+// own file: reordered, duplicated, or spliced-in blocks fail authentication.
+func gcmStreamBlockAAD(prefix []byte, blockIndex uint32) []byte {
+       aad := make([]byte, len(prefix)+4)
+       copy(aad, prefix)
+       binary.LittleEndian.PutUint32(aad[len(prefix):], blockIndex)
+
+       return aad
+}
+
+// standardOutputFile is an [EncryptedOutputFile] that seals fixed-size
+// plaintext blocks with AES-GCM as they are written, using the Iceberg AES
+// GCM Stream ("AGS1") wire format.
+type standardOutputFile struct {
+       icebergio.FileWriter
+
+       aead      cipher.AEAD
+       dek       []byte
+       aadPrefix []byte
+
+       // buf holds the pending plaintext block and sealBuf is scratch for its
+       // nonce, ciphertext and tag. Each is allocated once at its full-block
+       // size and reused for every block.
+       buf        []byte
+       sealBuf    []byte
+       blockIndex uint32
+       closed     bool
+       err        error
+
+       // encryptedLength is the number of bytes written to the underlying
+       // writer so far, header included. It becomes file_length in the key
+       // metadata, and only advances once a block has been written.
+       encryptedLength int64
+
+       // underlyingClosed records that FileWriter.Close has been called, so 
the
+       // underlying writer is closed at most once.
+       underlyingClosed bool
+
+       keyMetadata EncryptionKeyMetadata
+}
+
+var _ EncryptedOutputFile = (*standardOutputFile)(nil)
+
+// closeUnderlyingIgnoringError closes the underlying writer at most once.
+// The error is ignored: the caller is already reporting a more specific
+// failure (a flush error), and this is best-effort cleanup so a poisoned
+// writer never leaks its underlying file descriptor or connection.
+func (f *standardOutputFile) closeUnderlyingIgnoringError() {
+       if f.underlyingClosed {
+               return
+       }
+       f.underlyingClosed = true
+       _ = f.FileWriter.Close()
+}
+
+func (f *standardOutputFile) Write(p []byte) (int, error) {
+       if f.err != nil {
+               return 0, f.err
+       }
+       if f.closed {
+               return 0, ErrOutputFileClosed
+       }
+
+       total := len(p)
+       consumed := 0 // bytes of p appended into f.buf so far in this call
+       accepted := 0 // bytes of p known to be durably flushed; reported on 
failure
+       for len(p) > 0 {
+               space := gcmStreamPlainBlockSize - len(f.buf)
+               n := min(space, len(p))
+               f.buf = append(f.buf, p[:n]...)
+               p = p[n:]
+               consumed += n
+               if len(f.buf) == gcmStreamPlainBlockSize {
+                       if err := f.flushBlock(); err != nil {
+                               f.err = err
+                               f.closeUnderlyingIgnoringError()
+
+                               return accepted, err
+                       }
+                       accepted = consumed
+               }
+       }
+
+       return total, nil
+}
+
+// flushBlock seals and writes the currently buffered plaintext block using a
+// fresh random nonce, per the Iceberg AES GCM Stream format.
+// f.encryptedLength only advances once the ciphertext has reached the
+// underlying writer, so file_length never counts a block that failed to write.
+func (f *standardOutputFile) flushBlock() error {
+       if f.blockIndex == math.MaxUint32 {
+               return errors.New("encryption: cannot write block: exceeded 
maximum block count")
+       }
+
+       nonce := f.sealBuf[:gcmStreamNonceLength]
+       if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+               return fmt.Errorf("encryption: failed to generate block nonce: 
%w", err)
+       }
+
+       aad := gcmStreamBlockAAD(f.aadPrefix, f.blockIndex)
+       sealed := f.aead.Seal(nonce, nonce, f.buf, aad)
+       if _, err := f.FileWriter.Write(sealed); err != nil {
+               return fmt.Errorf("encryption: failed to write encrypted block: 
%w", err)
+       }
+       f.encryptedLength += int64(len(sealed))
+       f.blockIndex++
+       f.buf = f.buf[:0]
+
+       return nil
+}
+
+// ReadFrom copies from r, encrypting as data is written, satisfying
+// io.ReaderFrom (required by [icebergio.FileWriter]).
+func (f *standardOutputFile) ReadFrom(r io.Reader) (int64, error) {
+       buf := make([]byte, gcmStreamPlainBlockSize)
+       var total int64
+       for {
+               n, err := r.Read(buf)
+               if n > 0 {
+                       wn, werr := f.Write(buf[:n])
+                       total += int64(wn)
+                       if werr != nil {
+                               return total, werr
+                       }
+               }
+               if err == io.EOF {
+                       break
+               }
+               if err != nil {
+                       return total, err
+               }
+       }
+
+       return total, nil
+}
+
+// Close flushes any buffered partial block, writes an empty block 0 if the
+// file is empty, and finalizes the key metadata. closed is set, and
+// keyMetadata published, only once everything, including the underlying
+// Close, has succeeded. A failed Close poisons the writer (via f.err): a retry
+// returns the same error, and no key metadata is exposed for an output that
+// never finished.
+func (f *standardOutputFile) Close() error {
+       if f.err != nil {
+               return f.err
+       }
+       if f.closed {
+               return nil
+       }
+
+       // Java and iceberg-rust write block 0 even when the file is empty, so 
an
+       // empty file is 36 bytes (a header plus one 28-byte block), and
+       // iceberg-rust rejects anything shorter. Match them.
+       if len(f.buf) > 0 || f.blockIndex == 0 {
+               if err := f.flushBlock(); err != nil {
+                       f.err = err
+                       f.closeUnderlyingIgnoringError()
+
+                       return err
+               }
+       }
+
+       if err := f.FileWriter.Close(); err != nil {
+               f.underlyingClosed = true
+               f.err = fmt.Errorf("encryption: failed to close underlying 
writer: %w", err)
+
+               return f.err
+       }
+       f.underlyingClosed = true
+
+       f.keyMetadata = encodeStandardKeyMetadata(f.dek, f.aadPrefix, 
f.encryptedLength)
+       f.closed = true
+
+       return nil
+}
+
+// KeyMetadata returns the finalized per-file key metadata. It is only
+// populated after Close has succeeded. It contains the raw DEK.
+func (f *standardOutputFile) KeyMetadata() EncryptionKeyMetadata { return 
f.keyMetadata }
+
+// standardInputFile is an [EncryptedInputFile] that decrypts fixed-size
+// AES-GCM blocks on demand, supporting random access via ReadAt/Seek.
+//
+// The plaintext length is derived from the encrypted length (the file length
+// in key metadata, or the caller's), so ciphertext appended after the last
+// block is ignored. Both follow
+// from the AES GCM Stream spec's "File length" rule.
+//
+// ReadAt is safe, and intended, for concurrent use, matching the io.ReaderAt
+// contract: readBlock only holds cacheMu around the cache check/publish, not
+// across the underlying ReadAt or AEAD decryption, so concurrent calls for
+// distinct blocks run in parallel. A lost cache race (two goroutines
+// decrypting the same block) is harmless, just wasted work. Read and Seek
+// mutate the shared cursor (pos) and are not concurrent-safe; do not call them
+// from multiple goroutines on the same instance.
+//
+// The single-entry cache retains one decrypted block (1 MiB) for the lifetime
+// of the input file, not just the lifetime of a single read call.
+type standardInputFile struct {
+       underlying      icebergio.File
+       aead            cipher.AEAD
+       aadPrefix       []byte
+       plaintextLength int64
+       keyMetadata     EncryptionKeyMetadata
+
+       pos int64
+
+       // cacheMu guards cacheIdx/cacheBlock/cacheValid below. It is held only
+       // around the cache check and the cache publish in readBlock.
+       cacheMu    sync.Mutex
+       cacheIdx   int64
+       cacheBlock []byte
+       cacheValid bool
+}
+
+var _ EncryptedInputFile = (*standardInputFile)(nil)
+
+func (f *standardInputFile) numBlocks() int64 {
+       if f.plaintextLength == 0 {
+               return 0
+       }
+
+       // plaintextLength comes from key metadata or the caller and can be 
close
+       // to math.MaxInt64; 1 + (n-1)/size avoids the overflow that
+       // (n + size - 1)/size would hit there.
+       return 1 + (f.plaintextLength-1)/gcmStreamPlainBlockSize
+}
+
+// blockPlainLen returns the plaintext length of block idx, given the total
+// number of blocks.
+func (f *standardInputFile) blockPlainLen(idx, numBlocks int64) int64 {
+       if idx == numBlocks-1 {
+               return f.plaintextLength - idx*gcmStreamPlainBlockSize
+       }
+
+       return gcmStreamPlainBlockSize
+}
+
+// readBlock decrypts block idx, or returns it from the single-entry cache
+// if it was the most recently decrypted block. It checks the byte count
+// returned by ReadAt: a short read is reported as [ErrBlockTruncated]
+// (truncated storage), since zero-padding it into the AEAD would surface as a
+// misleading [ErrAuthenticationFailed].
+//
+// cacheMu is held only around the cache check and the cache publish at the
+// end, so concurrent readers do their I/O and decryption in parallel. Two
+// goroutines may decrypt the same block; the last one to finish publishes it.
+func (f *standardInputFile) readBlock(idx int64) ([]byte, error) {
+       f.cacheMu.Lock()
+       if f.cacheValid && f.cacheIdx == idx {
+               block := f.cacheBlock
+               f.cacheMu.Unlock()
+
+               return block, nil
+       }
+       f.cacheMu.Unlock()
+
+       numBlocks := f.numBlocks()
+       if idx < 0 || idx >= numBlocks {
+               return nil, fmt.Errorf("%w: block index %d out of range [0, 
%d)", ErrInvalidKeyMetadata, idx, numBlocks)
+       }
+       // The AAD carries the block index as 32 bits, and an encrypted length
+       // near math.MaxInt64 would otherwise imply more blocks than that.
+       if idx > math.MaxUint32 {
+               return nil, fmt.Errorf("%w: block index %d exceeds the maximum 
supported block count", ErrInvalidKeyMetadata, idx)
+       }
+
+       // idx < numBlocks and plaintextLength was derived from an int64 stream
+       // length, so this offset is no larger than that length and cannot
+       // overflow.
+       offset := gcmStreamHeaderLength + idx*gcmStreamCipherBlockSize
+
+       wantLen := f.blockPlainLen(idx, numBlocks) + gcmStreamBlockOverhead
+       ciphertext := make([]byte, wantLen)
+       n, err := f.underlying.ReadAt(ciphertext, offset)
+       if err != nil && !errors.Is(err, io.EOF) {
+               return nil, fmt.Errorf("encryption: failed to read block %d: 
%w", idx, err)
+       }
+       if int64(n) != wantLen {
+               return nil, fmt.Errorf("%w: block %d: read %d of %d expected 
ciphertext bytes", ErrBlockTruncated, idx, n, wantLen)
+       }
+
+       nonce, sealed := ciphertext[:gcmStreamNonceLength], 
ciphertext[gcmStreamNonceLength:]
+       aad := gcmStreamBlockAAD(f.aadPrefix, uint32(idx))
+
+       // Decrypt in place into this block's ciphertext buffer.
+       plaintext, err := f.aead.Open(sealed[:0], nonce, sealed, aad)
+       if err != nil {
+               return nil, fmt.Errorf("%w: block %d: %w", 
ErrAuthenticationFailed, idx, err)
+       }
+
+       f.cacheMu.Lock()
+       f.cacheIdx = idx
+       f.cacheBlock = plaintext
+       f.cacheValid = true
+       f.cacheMu.Unlock()
+
+       return plaintext, nil
+}
+
+func (f *standardInputFile) ReadAt(p []byte, off int64) (int, error) {
+       if len(p) == 0 {
+               return 0, nil
+       }
+       if off < 0 {
+               return 0, errors.New("encryption: ReadAt: negative offset")
+       }
+       if off >= f.plaintextLength {
+               return 0, io.EOF
+       }
+
+       var read int
+       for read < len(p) {
+               curOff := off + int64(read)
+               if curOff >= f.plaintextLength {
+                       break
+               }
+               idx := curOff / gcmStreamPlainBlockSize
+               block, err := f.readBlock(idx)
+               if err != nil {
+                       return read, err
+               }
+               inBlockOff := curOff - idx*gcmStreamPlainBlockSize
+               read += copy(p[read:], block[inBlockOff:])
+       }
+
+       var err error
+       if read < len(p) {
+               err = io.EOF
+       }
+
+       return read, err
+}
+
+func (f *standardInputFile) Read(p []byte) (int, error) {
+       n, err := f.ReadAt(p, f.pos)
+       f.pos += int64(n)
+
+       return n, err
+}
+
+func (f *standardInputFile) Seek(offset int64, whence int) (int64, error) {
+       var newPos int64
+       switch whence {
+       case io.SeekStart:
+               newPos = offset
+       case io.SeekCurrent:
+               newPos = f.pos + offset
+       case io.SeekEnd:
+               newPos = f.plaintextLength + offset
+       default:
+               return 0, fmt.Errorf("encryption: Seek: invalid whence %d", 
whence)
+       }
+       if newPos < 0 {
+               return 0, errors.New("encryption: Seek: negative position")
+       }
+       f.pos = newPos
+
+       return newPos, nil
+}
+
+func (f *standardInputFile) Close() error { return f.underlying.Close() }
+
+func (f *standardInputFile) Stat() (fs.FileInfo, error) {
+       info, err := f.underlying.Stat()
+       if err != nil {
+               return nil, err
+       }
+
+       return standardFileInfo{FileInfo: info, size: f.plaintextLength}, nil
+}
+
+// KeyMetadata returns the key metadata this file was decrypted with.
+func (f *standardInputFile) KeyMetadata() EncryptionKeyMetadata { return 
f.keyMetadata }
+
+// standardFileInfo overrides Size() to report the plaintext length rather
+// than the (larger) on-disk ciphertext length.
+type standardFileInfo struct {
+       fs.FileInfo
+       size int64
+}
+
+func (i standardFileInfo) Size() int64 { return i.size }
diff --git a/encryption/standard_manager_test.go 
b/encryption/standard_manager_test.go
new file mode 100644
index 000000000..49942809a
--- /dev/null
+++ b/encryption/standard_manager_test.go
@@ -0,0 +1,947 @@
+// 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.
+
+package encryption_test
+
+import (
+       "bytes"
+       "crypto/aes"
+       "crypto/cipher"
+       "encoding/binary"
+       "encoding/hex"
+       "errors"
+       "fmt"
+       "io"
+       "io/fs"
+       "math"
+       mrand "math/rand/v2"
+       "slices"
+       "testing"
+       "time"
+
+       "github.com/apache/iceberg-go/encryption"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       "golang.org/x/sync/errgroup"
+)
+
+// AES GCM Stream layout constants, restated here from the spec rather than
+// imported, so the tests do not share them with the code under test.
+const (
+       plainBlockSize = 1 << 20 // the one block size Java and iceberg-rust 
accept
+       blockOverhead  = 12 + 16 // nonce + GCM tag
+       headerLength   = 4 + 4   // "AGS1" magic + little-endian block length
+       testKeyID      = "table-key"
+)
+
+func newTestManager(opts ...encryption.StandardManagerOption) 
*encryption.StandardEncryptionManager {
+       return encryption.NewStandardEncryptionManager(opts...)
+}
+
+// testData returns n deterministic pseudo-random bytes, so that blocks differ
+// from one another and a swapped or misplaced block is detectable.
+func testData(n int) []byte {
+       r := mrand.New(mrand.NewPCG(1, 2))
+       b := make([]byte, n)
+       for i := range b {
+               b[i] = byte(r.Uint32())
+       }
+
+       return b
+}
+
+func encryptAll(t *testing.T, mgr *encryption.StandardEncryptionManager, 
plaintext []byte) (ciphertext []byte, keyMetadata 
encryption.EncryptionKeyMetadata) {
+       t.Helper()
+       fw := &memFileWriter{}
+       out, err := mgr.NewEncryptedOutputFile(t.Context(), fw, testKeyID)
+       require.NoError(t, err)
+       _, err = out.Write(plaintext)
+       require.NoError(t, err)
+       require.NoError(t, out.Close())
+
+       return fw.Bytes(), out.KeyMetadata()
+}
+
+func decryptAll(t *testing.T, mgr *encryption.StandardEncryptionManager, 
ciphertext []byte, keyMetadata encryption.EncryptionKeyMetadata) []byte {
+       t.Helper()
+       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), keyMetadata)
+       require.NoError(t, err)
+       data, err := io.ReadAll(in)
+       require.NoError(t, err)
+
+       return data
+}
+
+// expectedStreamLength is the length of the encrypted stream for n plaintext
+// bytes: the header, then ceil(n/block) blocks of nonce, ciphertext and tag,
+// and always at least block 0 (an empty file is a header plus one empty 
block).
+func expectedStreamLength(n int) int {
+       blocks := max(1, (n+plainBlockSize-1)/plainBlockSize)
+
+       return headerLength + n + blocks*blockOverhead
+}
+
+// ---------------------------------------------------------------------------
+// Independent AGS1 / StandardKeyMetadata codecs, built only from the standard
+// library and the spec text so that they share no code, and no bugs, with the
+// package under test.
+// ---------------------------------------------------------------------------
+
+func avroLong(v int64) []byte { return binary.AppendVarint(nil, v) }
+
+func avroBytes(b []byte) []byte { return append(avroLong(int64(len(b))), b...) 
}
+
+// avroKeyMetadata hand-encodes StandardKeyMetadata with both optional fields
+// present: version byte 1, then the Avro record
+// {encryption_key: bytes, aad_prefix: ["null", bytes], file_length: ["null", 
long]}.
+func avroKeyMetadata(dek, aadPrefix []byte, fileLength int64) []byte {
+       return slices.Concat([]byte{1}, avroBytes(dek), avroLong(1), 
avroBytes(aadPrefix), avroLong(1), avroLong(fileLength))
+}
+
+// javaKeyMetadata hand-encodes StandardKeyMetadata the way Java's
+// StandardEncryptionManager writes it for data files and manifests,
+// new StandardKeyMetadata(fileDek, aadPrefix): file_length takes the null
+// union branch.
+func javaKeyMetadata(dek, aadPrefix []byte) []byte {
+       return slices.Concat([]byte{1}, avroBytes(dek), avroLong(1), 
avroBytes(aadPrefix), avroLong(0))
+}
+
+type parsedKeyMetadata struct {
+       dek, aadPrefix []byte
+       fileLength     int64
+}
+
+func parseKeyMetadata(t *testing.T, km []byte) parsedKeyMetadata {
+       t.Helper()
+       require.NotEmpty(t, km)
+       require.Equal(t, byte(1), km[0], "version byte")
+       rest := km[1:]
+
+       readLong := func() int64 {
+               v, n := binary.Varint(rest)
+               require.Positive(t, n, "malformed varint")
+               rest = rest[n:]
+
+               return v
+       }
+       readBytes := func() []byte {
+               n := readLong()
+               require.GreaterOrEqual(t, n, int64(0))
+               require.LessOrEqual(t, n, int64(len(rest)))
+               b := rest[:n]
+               rest = rest[n:]
+
+               return b
+       }
+
+       var p parsedKeyMetadata
+       p.dek = readBytes()
+       require.EqualValues(t, 1, readLong(), "aad_prefix must be present 
(union branch 1)")
+       p.aadPrefix = readBytes()
+       require.EqualValues(t, 1, readLong(), "file_length must be present 
(union branch 1)")
+       p.fileLength = readLong()
+       require.Empty(t, rest, "trailing bytes after the record")
+
+       return p
+}
+
+func newGCM(t *testing.T, dek []byte) cipher.AEAD {
+       t.Helper()
+       block, err := aes.NewCipher(dek)
+       require.NoError(t, err)
+       gcm, err := cipher.NewGCM(block)
+       require.NoError(t, err)
+
+       return gcm
+}
+
+// blockAAD builds the spec's AAD, aadPrefix || little-endian uint32 block 
index.
+func blockAAD(aadPrefix []byte, index uint32) []byte {
+       return binary.LittleEndian.AppendUint32(slices.Clone(aadPrefix), index)
+}
+
+// decodeAGS1 reads an AGS1 stream the way the spec describes.
+func decodeAGS1(t *testing.T, stream, dek, aadPrefix []byte) []byte {
+       t.Helper()
+       require.GreaterOrEqual(t, len(stream), headerLength)
+       require.Equal(t, "AGS1", string(stream[:4]), "magic")
+       require.EqualValues(t, plainBlockSize, 
binary.LittleEndian.Uint32(stream[4:8]), "plain block length")
+
+       gcm := newGCM(t, dek)
+       var plaintext []byte
+       body := stream[headerLength:]
+       for index := uint32(0); len(body) > 0; index++ {
+               block := body[:min(len(body), plainBlockSize+blockOverhead)]
+               body = body[len(block):]
+               require.GreaterOrEqual(t, len(block), blockOverhead, "block 
%d", index)
+
+               nonce, sealed := block[:12], block[12:]
+               opened, err := gcm.Open(nil, nonce, sealed, blockAAD(aadPrefix, 
index))
+               require.NoError(t, err, "block %d", index)
+               plaintext = append(plaintext, opened...)
+       }
+
+       return plaintext
+}
+
+// encodeAGS1 writes an AGS1 stream the way Java does: block 0 is always
+// written, even for empty plaintext. The real writer draws a random nonce per
+// block; this one derives each block's nonce from its index (so it still
+// differs from block to block) to make the output reproducible. It is only
+// fit for tests.
+func encodeAGS1(t *testing.T, plaintext, dek, aadPrefix []byte) []byte {
+       t.Helper()
+       gcm := newGCM(t, dek)
+
+       stream := append([]byte("AGS1"), binary.LittleEndian.AppendUint32(nil, 
plainBlockSize)...)
+       for index := uint32(0); index == 0 || len(plaintext) > 0; index++ {
+               n := min(len(plaintext), plainBlockSize)
+               nonce := bytes.Repeat([]byte{byte(index) + 1}, 12)
+               stream = gcm.Seal(append(stream, nonce...), nonce, 
plaintext[:n], blockAAD(aadPrefix, index))
+               plaintext = plaintext[n:]
+       }
+
+       return stream
+}
+
+// ---------------------------------------------------------------------------
+// Round trips and layout
+// ---------------------------------------------------------------------------
+
+func TestStandardEncryptionManager_RoundTrip(t *testing.T) {
+       sizes := []int{
+               0,
+               1,
+               13,
+               plainBlockSize - 1,
+               plainBlockSize,
+               plainBlockSize + 1,
+               2 * plainBlockSize,
+               2*plainBlockSize + 12345,
+       }
+       for _, size := range sizes {
+               t.Run(fmt.Sprintf("%d bytes", size), func(t *testing.T) {
+                       mgr := newTestManager()
+                       plaintext := testData(size)
+
+                       ciphertext, keyMetadata := encryptAll(t, mgr, plaintext)
+                       assert.Len(t, ciphertext, expectedStreamLength(size), 
"no empty trailing block, and block 0 even when empty")
+                       assert.EqualValues(t, len(ciphertext), 
parseKeyMetadata(t, keyMetadata).fileLength, "file_length is the encrypted 
length")
+
+                       got := decryptAll(t, mgr, ciphertext, keyMetadata)
+                       assert.True(t, bytes.Equal(plaintext, got), "decrypted 
bytes differ from the plaintext")
+               })
+       }
+}
+
+// Java writes block 0 even for an empty file, and iceberg-rust rejects any
+// stream shorter than the resulting 8 + 28 = 36 bytes.
+func TestStandardEncryptionManager_EmptyFileUsesJavaLayout(t *testing.T) {
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, nil)
+
+       assert.Len(t, ciphertext, 36)
+       assert.EqualValues(t, 36, parseKeyMetadata(t, keyMetadata).fileLength)
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), keyMetadata)
+       require.NoError(t, err)
+       info, err := in.Stat()
+       require.NoError(t, err)
+       assert.EqualValues(t, 0, info.Size())
+       got, err := io.ReadAll(in)
+       require.NoError(t, err)
+       assert.Empty(t, got)
+}
+
+func TestStandardEncryptionManager_RandomAccess(t *testing.T) {
+       mgr := newTestManager()
+       plaintext := testData(2*plainBlockSize + 100)
+
+       ciphertext, keyMetadata := encryptAll(t, mgr, plaintext)
+       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), keyMetadata)
+       require.NoError(t, err)
+
+       // Read a slice spanning a block boundary.
+       buf := make([]byte, 20)
+       n, err := in.ReadAt(buf, plainBlockSize-10)
+       require.NoError(t, err)
+       assert.Equal(t, plaintext[plainBlockSize-10:plainBlockSize+10], buf[:n])
+
+       // A read running off the end returns what is left, with io.EOF.
+       tail := make([]byte, 200)
+       n, err = in.ReadAt(tail, 2*plainBlockSize)
+       assert.Equal(t, io.EOF, err)
+       assert.Equal(t, plaintext[2*plainBlockSize:], tail[:n])
+
+       // Seek + Read.
+       pos, err := in.Seek(5, io.SeekStart)
+       require.NoError(t, err)
+       assert.Equal(t, int64(5), pos)
+
+       rest, err := io.ReadAll(in)
+       require.NoError(t, err)
+       assert.True(t, bytes.Equal(plaintext[5:], rest))
+}
+
+func TestStandardEncryptionManager_EmptyFile_ZeroLengthReadAt(t *testing.T) {
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, nil)
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), keyMetadata)
+       require.NoError(t, err)
+
+       n, err := in.ReadAt(nil, 0)
+       assert.NoError(t, err, "a zero-length ReadAt must return (0, nil), like 
os.File")
+       assert.Equal(t, 0, n)
+}
+
+func TestStandardEncryptionManager_ReadFrom(t *testing.T) {
+       mgr := newTestManager()
+       fw := &memFileWriter{}
+       out, err := mgr.NewEncryptedOutputFile(t.Context(), fw, testKeyID)
+       require.NoError(t, err)
+
+       plaintext := testData(2*plainBlockSize + 7)
+       n, err := out.ReadFrom(bytes.NewReader(plaintext))
+       require.NoError(t, err)
+       assert.EqualValues(t, len(plaintext), n)
+       require.NoError(t, out.Close())
+
+       got := decryptAll(t, mgr, fw.Bytes(), out.KeyMetadata())
+       assert.True(t, bytes.Equal(plaintext, got))
+}
+
+func TestStandardEncryptionManager_Stat(t *testing.T) {
+       mgr := newTestManager()
+       plaintext := []byte("hello iceberg world, this is a stat test")
+       ciphertext, keyMetadata := encryptAll(t, mgr, plaintext)
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), keyMetadata)
+       require.NoError(t, err)
+
+       info, err := in.Stat()
+       require.NoError(t, err)
+       assert.EqualValues(t, len(plaintext), info.Size(), "Stat must report 
the plaintext length, not the on-disk ciphertext length")
+       assert.Less(t, info.Size(), int64(len(ciphertext)), "ciphertext must be 
larger than plaintext due to the header and per-block nonce/tag overhead")
+}
+
+// ---------------------------------------------------------------------------
+// Conformance with the AGS1 spec and StandardKeyMetadata
+// ---------------------------------------------------------------------------
+
+// Round trips through the package can't catch a wrong AAD encoding (say, a
+// big-endian block index), because writer and reader share gcmStreamBlockAAD.
+// This decodes with crypto/aes and crypto/cipher alone, so such an encoding
+// fails at block 1.
+func TestStandardEncryptionManager_OutputDecodesWithSpecDecoder(t *testing.T) {
+       for _, size := range []int{0, 1, plainBlockSize, 2*plainBlockSize + 17} 
{
+               t.Run(fmt.Sprintf("%d bytes", size), func(t *testing.T) {
+                       plaintext := testData(size)
+                       ciphertext, keyMetadata := encryptAll(t, 
newTestManager(), plaintext)
+
+                       meta := parseKeyMetadata(t, keyMetadata)
+                       assert.Len(t, meta.dek, 16, "the default data key 
length is 128-bit")
+                       assert.Len(t, meta.aadPrefix, 16)
+                       assert.EqualValues(t, len(ciphertext), meta.fileLength)
+
+                       got := decodeAGS1(t, ciphertext, meta.dek, 
meta.aadPrefix)
+                       assert.True(t, bytes.Equal(plaintext, got))
+               })
+       }
+}
+
+// The reverse direction: a stream and key metadata produced by the
+// independent codecs above must be readable by the manager.
+func TestStandardEncryptionManager_ReadsSpecBuiltStream(t *testing.T) {
+       dek, err := hex.DecodeString("000102030405060708090a0b0c0d0e0f")
+       require.NoError(t, err)
+       aadPrefix, err := hex.DecodeString("a0a1a2a3a4a5a6a7a8a9aaabacadaeaf")
+       require.NoError(t, err)
+
+       // Known-answer vector: StandardKeyMetadata for an empty file, written 
out
+       // by hand from the Avro binary encoding. 0x01 is the version, 0x20 the
+       // zig-zag length 16, each 0x02 selects the non-null union branch, and 
0x48
+       // is the zig-zag encoding of the 36-byte file length.
+       const wantEmpty = "01" + "20" + "000102030405060708090a0b0c0d0e0f" + 
"02" + "20" + "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf" + "02" + "48"
+       assert.Equal(t, wantEmpty, hex.EncodeToString(avroKeyMetadata(dek, 
aadPrefix, 36)), "the test encoder must match the hand-written vector")
+
+       for _, size := range []int{0, 1, plainBlockSize, 2*plainBlockSize + 17} 
{
+               t.Run(fmt.Sprintf("%d bytes", size), func(t *testing.T) {
+                       plaintext := testData(size)
+                       stream := encodeAGS1(t, plaintext, dek, aadPrefix)
+                       require.Len(t, stream, expectedStreamLength(size))
+
+                       got := decryptAll(t, newTestManager(), stream, 
avroKeyMetadata(dek, aadPrefix, int64(len(stream))))
+                       assert.True(t, bytes.Equal(plaintext, got))
+               })
+       }
+}
+
+// Java records no file_length for data files and manifests; the reader then
+// takes the encrypted length from the caller (DataFile.FileSizeBytes or
+// ManifestFile.Length).
+func TestStandardEncryptionManager_ReadsJavaKeyMetadataWithCallerLength(t 
*testing.T) {
+       dek, err := hex.DecodeString("000102030405060708090a0b0c0d0e0f")
+       require.NoError(t, err)
+       aadPrefix, err := hex.DecodeString("a0a1a2a3a4a5a6a7a8a9aaabacadaeaf")
+       require.NoError(t, err)
+
+       // Known-answer vector for new StandardKeyMetadata(dek, aadPrefix): the
+       // trailing 0x00 selects the null branch of the file_length union.
+       const wantJava = "01" + "20" + "000102030405060708090a0b0c0d0e0f" + 
"02" + "20" + "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf" + "00"
+       keyMetadata := javaKeyMetadata(dek, aadPrefix)
+       assert.Equal(t, wantJava, hex.EncodeToString(keyMetadata), "the test 
encoder must match the hand-written vector")
+
+       mgr := newTestManager()
+       for _, size := range []int{0, 1, plainBlockSize, 2*plainBlockSize + 17} 
{
+               t.Run(fmt.Sprintf("%d bytes", size), func(t *testing.T) {
+                       plaintext := testData(size)
+                       stream := encodeAGS1(t, plaintext, dek, aadPrefix)
+
+                       in, err := 
mgr.NewDecryptedInputFileWithLength(t.Context(), newMemFile(stream), 
int64(len(stream)), keyMetadata)
+                       require.NoError(t, err)
+                       got, err := io.ReadAll(in)
+                       require.NoError(t, err)
+                       assert.True(t, bytes.Equal(plaintext, got))
+
+                       _, err = mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(stream), keyMetadata)
+                       assert.ErrorIs(t, err, 
encryption.ErrInvalidKeyMetadata, "without file_length or a caller length there 
is no trusted length")
+               })
+       }
+
+       t.Run("file_length takes precedence over the caller length", func(t 
*testing.T) {
+               plaintext := testData(plainBlockSize + 5)
+               stream := encodeAGS1(t, plaintext, dek, aadPrefix)
+               meta := avroKeyMetadata(dek, aadPrefix, int64(len(stream)))
+
+               in, err := mgr.NewDecryptedInputFileWithLength(t.Context(), 
newMemFile(stream), headerLength+blockOverhead, meta)
+               require.NoError(t, err)
+               got, err := io.ReadAll(in)
+               require.NoError(t, err)
+               assert.True(t, bytes.Equal(plaintext, got))
+       })
+
+       t.Run("caller length is validated", func(t *testing.T) {
+               stream := encodeAGS1(t, nil, dek, aadPrefix)
+               for _, length := range []int64{-1, 0, headerLength + 
blockOverhead - 1} {
+                       _, err := 
mgr.NewDecryptedInputFileWithLength(t.Context(), newMemFile(stream), length, 
keyMetadata)
+                       assert.ErrorIs(t, err, 
encryption.ErrInvalidKeyMetadata, "length %d", length)
+               }
+       })
+
+       t.Run("short caller length fails authentication", func(t *testing.T) {
+               // A caller length that drops the tail of the last block makes 
that
+               // block fail authentication.
+               stream := encodeAGS1(t, testData(100), dek, aadPrefix)
+               in, err := mgr.NewDecryptedInputFileWithLength(t.Context(), 
newMemFile(stream), int64(len(stream)-10), keyMetadata)
+               require.NoError(t, err)
+               _, err = io.ReadAll(in)
+               assert.ErrorIs(t, err, encryption.ErrAuthenticationFailed)
+       })
+}
+
+// TestStandardEncryptionManager_BlockNoncesAreUnique pins the one GCM
+// property whose loss is catastrophic and silent: reusing a nonce under a
+// single key leaks the plaintext XOR of the two blocks and the GHASH
+// authentication key, yet a file sealed with a reused (or constant) nonce
+// still round-trips and decrypts correctly, so no round-trip, tamper, or
+// reorder test would ever catch a regression here. Sealing identical
+// plaintext in every block makes any nonce collision directly visible as
+// matching ciphertext bytes.
+func TestStandardEncryptionManager_BlockNoncesAreUnique(t *testing.T) {
+       const numBlocks = 4
+       // Every block carries byte-for-byte identical plaintext.
+       plaintext := bytes.Repeat([]byte("0123456789abcdef"), 
numBlocks*plainBlockSize/16)
+
+       ciphertext, _ := encryptAll(t, newTestManager(), plaintext)
+       require.Len(t, ciphertext, 
headerLength+numBlocks*(plainBlockSize+blockOverhead))
+
+       seenNonceAt := make(map[string]int, numBlocks)
+       cipherBlocks := make([][]byte, numBlocks)
+       for i := range numBlocks {
+               offset := headerLength + i*(plainBlockSize+blockOverhead)
+               block := ciphertext[offset : 
offset+plainBlockSize+blockOverhead]
+               nonce := string(block[:12])
+
+               if prev, ok := seenNonceAt[nonce]; ok {
+                       t.Fatalf("block %d reused the nonce from block %d: GCM 
nonce reuse under a single key leaks the plaintext XOR and the GHASH 
authentication key", i, prev)
+               }
+               seenNonceAt[nonce] = i
+               cipherBlocks[i] = block
+       }
+
+       for i := 1; i < numBlocks; i++ {
+               assert.False(t, bytes.Equal(cipherBlocks[0], cipherBlocks[i]), 
"identical plaintext in blocks 0 and %d must not produce identical ciphertext", 
i)
+       }
+}
+
+func TestStandardEncryptionManager_DataKeyLength(t *testing.T) {
+       t.Run("valid lengths", func(t *testing.T) {
+               for _, length := range []int{16, 24, 32} {
+                       mgr := 
newTestManager(encryption.WithStandardDataKeyLength(length))
+                       plaintext := testData(1000)
+
+                       ciphertext, keyMetadata := encryptAll(t, mgr, plaintext)
+                       assert.Len(t, parseKeyMetadata(t, keyMetadata).dek, 
length)
+                       assert.Equal(t, plaintext, decryptAll(t, mgr, 
ciphertext, keyMetadata))
+               }
+       })
+
+       t.Run("invalid lengths", func(t *testing.T) {
+               for _, length := range []int{-1, 0, 15, 17, 33} {
+                       mgr := 
newTestManager(encryption.WithStandardDataKeyLength(length))
+                       _, err := mgr.NewEncryptedOutputFile(t.Context(), 
&memFileWriter{}, testKeyID)
+                       require.Error(t, err, "length %d", length)
+                       assert.ErrorIs(t, err, encryption.ErrInvalidKeyLength)
+               }
+       })
+}
+
+// ---------------------------------------------------------------------------
+// Fail-closed behaviour and untrusted input
+// ---------------------------------------------------------------------------
+
+func TestStandardEncryptionManager_OutputFile_EmptyKeyIDRejected(t *testing.T) 
{
+       _, err := newTestManager().NewEncryptedOutputFile(t.Context(), 
&memFileWriter{}, "")
+       require.Error(t, err)
+       assert.ErrorIs(t, err, encryption.ErrKeyIDRequired)
+}
+
+func TestStandardEncryptionManager_InputFile_EmptyKeyMetadataRejected(t 
*testing.T) {
+       _, err := newTestManager().NewDecryptedInputFile(t.Context(), 
newMemFile(nil), nil)
+       require.Error(t, err)
+       assert.ErrorIs(t, err, encryption.ErrKeyMetadataRequired)
+}
+
+func TestStandardEncryptionManager_TamperedBlockFailsAuthentication(t 
*testing.T) {
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, 
testData(2*plainBlockSize))
+       // Flip a bit inside the first block's ciphertext, past the 8-byte AES
+       // GCM Stream header and 12-byte nonce; flipping the header instead
+       // would fail earlier, at stream-header validation.
+       ciphertext[headerLength+12] ^= 0xFF
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), keyMetadata)
+       require.NoError(t, err)
+
+       _, err = io.ReadAll(in)
+       require.Error(t, err)
+       assert.ErrorIs(t, err, encryption.ErrAuthenticationFailed)
+}
+
+func TestStandardEncryptionManager_ReorderedBlocksFailAuthentication(t 
*testing.T) {
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, 
testData(2*plainBlockSize)) // exactly two full blocks
+
+       // Swap the two blocks: with a random nonce per block, only the AAD 
(which
+       // binds a block to its position) can catch this.
+       const physical = plainBlockSize + blockOverhead
+       block0 := slices.Clone(ciphertext[headerLength : headerLength+physical])
+       block1 := slices.Clone(ciphertext[headerLength+physical : 
headerLength+2*physical])
+       copy(ciphertext[headerLength:], block1)
+       copy(ciphertext[headerLength+physical:], block0)
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), keyMetadata)
+       require.NoError(t, err)
+
+       _, err = io.ReadAll(in)
+       require.Error(t, err)
+       assert.ErrorIs(t, err, encryption.ErrAuthenticationFailed, "swapping 
blocks must be detected: the AAD binds each block to its position")
+}
+
+func TestStandardEncryptionManager_WrongKeyMaterialFailsAuthentication(t 
*testing.T) {
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, []byte("hello iceberg"))
+       good := parseKeyMetadata(t, keyMetadata)
+
+       tests := map[string][]byte{
+               "wrong DEK":        avroKeyMetadata(bytes.Repeat([]byte{0x55}, 
len(good.dek)), good.aadPrefix, good.fileLength),
+               "wrong AAD prefix": avroKeyMetadata(good.dek, 
bytes.Repeat([]byte{0x55}, len(good.aadPrefix)), good.fileLength),
+       }
+       for name, meta := range tests {
+               t.Run(name, func(t *testing.T) {
+                       in, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext), meta)
+                       require.NoError(t, err)
+
+                       _, err = io.ReadAll(in)
+                       require.Error(t, err)
+                       assert.ErrorIs(t, err, 
encryption.ErrAuthenticationFailed)
+               })
+       }
+}
+
+func TestStandardEncryptionManager_InvalidStreamHeaderRejected(t *testing.T) {
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, []byte("hello iceberg"))
+
+       setBlockLength := func(n uint32) func([]byte) {
+               return func(b []byte) { binary.LittleEndian.PutUint32(b[4:8], 
n) }
+       }
+       tests := map[string]func([]byte){
+               "magic mismatch":              func(b []byte) { copy(b[:4], 
"XXXX") },
+               "zero block length":           setBlockLength(0),
+               "64 KiB block length":         setBlockLength(64 * 1024),
+               "2 MiB block length":          setBlockLength(2 * 
plainBlockSize),
+               "maximum uint32 block length": setBlockLength(math.MaxUint32),
+       }
+       for name, mutate := range tests {
+               t.Run(name, func(t *testing.T) {
+                       stream := slices.Clone(ciphertext)
+                       mutate(stream)
+
+                       _, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(stream), keyMetadata)
+                       require.Error(t, err)
+                       assert.ErrorIs(t, err, 
encryption.ErrInvalidStreamHeader)
+               })
+       }
+
+       t.Run("truncated header", func(t *testing.T) {
+               _, err := mgr.NewDecryptedInputFile(t.Context(), 
newMemFile(ciphertext[:5]), keyMetadata)
+               require.Error(t, err)
+               assert.ErrorIs(t, err, encryption.ErrInvalidStreamHeader)
+       })
+}
+
+func TestStandardEncryptionManager_InvalidKeyMetadataRejectedOnRead(t 
*testing.T) {
+       dek := bytes.Repeat([]byte{0x11}, 16)
+       aad := bytes.Repeat([]byte{0x22}, 16)
+       valid := func(fileLength int64) []byte { return avroKeyMetadata(dek, 
aad, fileLength) }
+       const physical = plainBlockSize + blockOverhead
+
+       tests := []struct {
+               name    string
+               meta    []byte
+               wantErr error
+               wantMsg string
+       }{
+               {"unsupported version", slices.Concat([]byte{2}, 
valid(36)[1:]), encryption.ErrUnsupportedKeyMetadataVersion, ""},
+               {"version byte only", []byte{1}, 
encryption.ErrInvalidKeyMetadata, "varint"},
+               {"truncated mid-record", valid(36)[:20], 
encryption.ErrInvalidKeyMetadata, "out of range"},
+               {"trailing bytes", append(valid(36), 0), 
encryption.ErrInvalidKeyMetadata, "trailing"},
+               {"oversized varint", slices.Concat([]byte{1}, 
bytes.Repeat([]byte{0xff}, 11)), encryption.ErrInvalidKeyMetadata, "varint"},
+               {"negative bytes length", slices.Concat([]byte{1}, 
avroLong(-1)), encryption.ErrInvalidKeyMetadata, "out of range"},
+               {"bytes length beyond the record", slices.Concat([]byte{1}, 
avroLong(1<<40)), encryption.ErrInvalidKeyMetadata, "out of range"},
+               {"unknown union branch", slices.Concat([]byte{1}, 
avroBytes(dek), avroLong(2)), encryption.ErrInvalidKeyMetadata, "union branch"},
+               {"missing aad prefix", slices.Concat([]byte{1}, avroBytes(dek), 
avroLong(0), avroLong(1), avroLong(36)), encryption.ErrInvalidKeyMetadata, 
"aad-prefix must be exactly 16 bytes, got 0"},
+               {"empty aad prefix", avroKeyMetadata(dek, nil, 36), 
encryption.ErrInvalidKeyMetadata, "aad-prefix must be exactly 16 bytes, got 0"},
+               {"short aad prefix", avroKeyMetadata(dek, aad[:15], 36), 
encryption.ErrInvalidKeyMetadata, "aad-prefix must be exactly 16 bytes, got 
15"},
+               {"long aad prefix", avroKeyMetadata(dek, 
append(slices.Clone(aad), 0), 36), encryption.ErrInvalidKeyMetadata, 
"aad-prefix must be exactly 16 bytes, got 17"},
+               {"missing file length and no caller length", 
javaKeyMetadata(dek, aad), encryption.ErrInvalidKeyMetadata, "file-length is 
required"},
+               {"negative file length", valid(-1), 
encryption.ErrInvalidKeyMetadata, "shorter than"},
+               {"file length below the 36-byte minimum", valid(35), 
encryption.ErrInvalidKeyMetadata, "shorter than"},
+               {"file length ends inside a nonce", valid(headerLength + 
physical + 5), encryption.ErrInvalidKeyMetadata, "nonce and tag"},
+               {"invalid DEK length", avroKeyMetadata(dek[:15], aad, 36), 
encryption.ErrInvalidKeyLength, ""},
+               {"empty DEK", avroKeyMetadata(nil, aad, 36), 
encryption.ErrInvalidKeyLength, ""},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       _, err := 
newTestManager().NewDecryptedInputFile(t.Context(), newMemFile(nil), tt.meta)
+                       require.Error(t, err)
+                       assert.ErrorIs(t, err, tt.wantErr)
+                       if tt.wantMsg != "" {
+                               assert.ErrorContains(t, err, tt.wantMsg)
+                       }
+               })
+       }
+}
+
+// A file length near math.MaxInt64 is well-formed, so construction succeeds,
+// but reading must fail closed without overflowing or allocating more than
+// one block.
+func TestStandardEncryptionManager_HugeFileLengthFailsClosedOnRead(t 
*testing.T) {
+       mgr := newTestManager()
+       dek := bytes.Repeat([]byte{0x11}, 16)
+       aad := bytes.Repeat([]byte{0x22}, 16)
+       header := encodeAGS1(t, nil, dek, aad)[:headerLength]
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), newMemFile(header), 
avroKeyMetadata(dek, aad, math.MaxInt64))
+       require.NoError(t, err)
+
+       buf := make([]byte, 16)
+       _, err = in.ReadAt(buf, 0)
+       assert.ErrorIs(t, err, encryption.ErrBlockTruncated, "block 0 lies 
beyond the real file")
+
+       _, err = in.ReadAt(buf, (math.MaxUint32+1)*plainBlockSize)
+       assert.ErrorIs(t, err, encryption.ErrInvalidKeyMetadata, "the AAD only 
has 32 bits for the block index")
+}
+
+// shortReadFile is an icebergio.File stub whose ReadAt returns fewer bytes
+// than requested along with io.EOF once truncateAt is reached, mimicking a
+// real backend (S3, local fs) reading a genuinely truncated file. A plain
+// bytes.Reader always fills the requested slice, which is why the round-trip
+// tests never exercise this path.
+type shortReadFile struct {
+       *memFile
+       truncateAt int64
+}
+
+func (f *shortReadFile) ReadAt(p []byte, off int64) (int, error) {
+       if off >= f.truncateAt {
+               return 0, io.EOF
+       }
+       if off+int64(len(p)) > f.truncateAt {
+               p = p[:f.truncateAt-off]
+       }
+       n, err := f.memFile.ReadAt(p, off)
+       if err == nil && int64(n) < int64(len(p)) {
+               err = io.EOF
+       }
+
+       return n, err
+}
+
+func TestStandardEncryptionManager_TruncatedBackendReadReportsBlockTruncated(t 
*testing.T) {
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, testData(100))
+       truncated := &shortReadFile{memFile: newMemFile(ciphertext), 
truncateAt: int64(len(ciphertext) - 5)}
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), truncated, 
keyMetadata)
+       require.NoError(t, err)
+
+       _, err = io.ReadAll(in)
+       require.Error(t, err)
+       assert.ErrorIs(t, err, encryption.ErrBlockTruncated)
+       assert.NotErrorIs(t, err, encryption.ErrCiphertextTooShort, "a 
truncated block read is distinct from a too-short KMS-wrapped key/payload")
+       assert.NotErrorIs(t, err, encryption.ErrAuthenticationFailed, "a 
truncated read must not be misreported as tampering")
+}
+
+// The plaintext length is derived from the file length in key metadata, so
+// bytes appended after the last block are ignored.
+func TestStandardEncryptionManager_TrailingBytesAfterLastBlockIgnored(t 
*testing.T) {
+       mgr := newTestManager()
+       plaintext := testData(plainBlockSize + 10)
+       ciphertext, keyMetadata := encryptAll(t, mgr, plaintext)
+
+       got := decryptAll(t, mgr, append(ciphertext, []byte("trailing 
garbage")...), keyMetadata)
+       assert.True(t, bytes.Equal(plaintext, got))
+}
+
+// ---------------------------------------------------------------------------
+// Write and close failures
+// ---------------------------------------------------------------------------
+
+// failAfterNWriter is an icebergio.FileWriter stub that fails the Nth call
+// to Write, simulating a mid-stream flush failure on the underlying storage.
+// It also counts Close calls, since memFileWriter.Close is a no-op and would
+// otherwise mask a leaked/never-closed underlying writer.
+type failAfterNWriter struct {
+       memFileWriter
+       failAt int
+       writes int
+       closes int
+}
+
+func (w *failAfterNWriter) Write(p []byte) (int, error) {
+       w.writes++
+       if w.writes == w.failAt {
+               return 0, errors.New("simulated write failure")
+       }
+
+       return w.memFileWriter.Write(p)
+}
+
+func (w *failAfterNWriter) Close() error {
+       w.closes++
+
+       return w.memFileWriter.Close()
+}
+
+func TestStandardEncryptionManager_FlushFailurePoisonsWriterAndClose(t 
*testing.T) {
+       mgr := newTestManager()
+       // Write call 1 is the AES GCM Stream header written by
+       // NewEncryptedOutputFile; call 2 flushes the first block; call 3 
flushes
+       // the second block and fails.
+       fw := &failAfterNWriter{failAt: 3}
+       out, err := mgr.NewEncryptedOutputFile(t.Context(), fw, testKeyID)
+       require.NoError(t, err)
+
+       // The first block flushes fine; the second block's flush fails.
+       n, err := out.Write(make([]byte, 2*plainBlockSize))
+       require.Error(t, err)
+       assert.Equal(t, plainBlockSize, n, "only the first block reached 
storage")
+       assert.Equal(t, 1, fw.closes, "a poisoned flush must close the 
underlying writer, not leak it")
+
+       // A subsequent Write must return the same sticky error, not attempt 
more
+       // I/O, and not the post-Close error: the writer was poisoned, not 
closed.
+       _, err2 := out.Write([]byte("c"))
+       require.Error(t, err2)
+       assert.ErrorIs(t, err2, err)
+       assert.NotErrorIs(t, err2, encryption.ErrOutputFileClosed)
+       assert.NotErrorIs(t, err2, fs.ErrClosed)
+
+       // Close must report the failure rather than silently succeeding.
+       closeErr := out.Close()
+       require.Error(t, closeErr)
+       assert.Nil(t, out.KeyMetadata(), "key metadata must not be finalized 
when the file failed to write")
+
+       // A retried Close must keep reporting the same error, not nil.
+       closeErr2 := out.Close()
+       require.Error(t, closeErr2)
+       assert.Equal(t, 1, fw.closes, "a retried Close must not close the 
underlying writer again")
+}
+
+func TestStandardEncryptionManager_HeaderWriteFailureClosesWriter(t 
*testing.T) {
+       fw := &failAfterNWriter{failAt: 1}
+       out, err := newTestManager().NewEncryptedOutputFile(t.Context(), fw, 
testKeyID)
+       require.Error(t, err)
+       assert.Nil(t, out)
+       assert.Equal(t, 1, fw.closes, "a failed header write must close the 
underlying writer, not leak it")
+}
+
+func TestStandardEncryptionManager_WriteAfterCloseRejected(t *testing.T) {
+       out, err := newTestManager().NewEncryptedOutputFile(t.Context(), 
&memFileWriter{}, testKeyID)
+       require.NoError(t, err)
+       require.NoError(t, out.Close())
+
+       _, err = out.Write([]byte("late"))
+       require.Error(t, err)
+       assert.ErrorIs(t, err, encryption.ErrOutputFileClosed)
+       assert.ErrorIs(t, err, fs.ErrClosed)
+}
+
+// closeFailWriter is an icebergio.FileWriter stub whose Close always fails
+// even though every Write succeeds, simulating a backend where the final
+// flush/commit (e.g. an S3 multipart completion) fails independently of the
+// preceding writes.
+type closeFailWriter struct {
+       memFileWriter
+       closes int
+}
+
+func (w *closeFailWriter) Close() error {
+       w.closes++
+
+       return errors.New("simulated close failure")
+}
+
+func TestStandardEncryptionManager_UnderlyingCloseFailurePoisonsWriter(t 
*testing.T) {
+       fw := &closeFailWriter{}
+       out, err := newTestManager().NewEncryptedOutputFile(t.Context(), fw, 
testKeyID)
+       require.NoError(t, err)
+
+       _, err = out.Write([]byte("hello"))
+       require.NoError(t, err)
+
+       closeErr := out.Close()
+       require.Error(t, closeErr)
+       assert.Nil(t, out.KeyMetadata(), "key metadata must not be finalized 
when the underlying Close fails")
+
+       // A retried Close must keep reporting the same error, not nil, and must
+       // not attempt to close the underlying writer again.
+       closeErr2 := out.Close()
+       require.Error(t, closeErr2)
+       assert.ErrorIs(t, closeErr2, closeErr)
+       assert.Equal(t, 1, fw.closes, "a retried Close must not re-close the 
underlying writer")
+
+       _, err = out.Write([]byte("late"))
+       require.Error(t, err)
+       assert.ErrorIs(t, err, closeErr, "a subsequent Write must report the 
same sticky error")
+}
+
+// ---------------------------------------------------------------------------
+// Block cache and concurrency
+// ---------------------------------------------------------------------------
+
+// countingReadAtFile wraps a memFile and counts ReadAt calls, to verify that
+// repeated small reads landing in the same block reuse the decrypted-block
+// cache instead of re-decrypting on every call.
+type countingReadAtFile struct {
+       *memFile
+       reads int
+}
+
+func (f *countingReadAtFile) ReadAt(p []byte, off int64) (int, error) {
+       f.reads++
+
+       return f.memFile.ReadAt(p, off)
+}
+
+func 
TestStandardEncryptionManager_RepeatedSmallReadsReuseDecryptedBlockCache(t 
*testing.T) {
+       mgr := newTestManager()
+       plaintext := testData(plainBlockSize + 5000) // a full block, then a 
5000-byte tail block
+
+       ciphertext, keyMetadata := encryptAll(t, mgr, plaintext)
+       counting := &countingReadAtFile{memFile: newMemFile(ciphertext)}
+
+       in, err := mgr.NewDecryptedInputFile(t.Context(), counting, keyMetadata)
+       require.NoError(t, err)
+       baseline := counting.reads // the stream header was already read once 
above
+
+       buf := make([]byte, 1)
+       readOneByteAt := func(off int64) {
+               _, err := in.Seek(off, io.SeekStart)
+               require.NoError(t, err)
+               n, err := in.Read(buf)
+               require.NoError(t, err)
+               require.Equal(t, 1, n)
+       }
+       for off := range int64(5000) {
+               readOneByteAt(off) // all in block 0
+       }
+       for off := range int64(5000) {
+               readOneByteAt(plainBlockSize + off) // all in block 1
+       }
+
+       assert.LessOrEqual(t, counting.reads-baseline, 2, "10000 one-byte reads 
across two blocks must decrypt each block at most once")
+}
+
+// slowReadFile wraps a memFile and sleeps before every ReadAt, simulating a
+// remote backend (e.g. S3) with real network latency. It is used to check
+// that concurrent ReadAt calls for distinct blocks overlap instead of queuing
+// behind readBlock's cache lock.
+type slowReadFile struct {
+       *memFile
+       delay time.Duration
+}
+
+func (f *slowReadFile) ReadAt(p []byte, off int64) (int, error) {
+       time.Sleep(f.delay)
+
+       return f.memFile.ReadAt(p, off)
+}
+
+func TestStandardEncryptionManager_ConcurrentReadAtOverlaps(t *testing.T) {
+       const (
+               numBlocks = 8
+               delay     = 20 * time.Millisecond
+       )
+       mgr := newTestManager()
+       ciphertext, keyMetadata := encryptAll(t, mgr, 
testData(numBlocks*plainBlockSize))
+
+       slow := &slowReadFile{memFile: newMemFile(ciphertext), delay: delay}
+       in, err := mgr.NewDecryptedInputFile(t.Context(), slow, keyMetadata) // 
reads the header; not timed below
+       require.NoError(t, err)
+
+       start := time.Now()
+       var g errgroup.Group
+       for i := range numBlocks {
+               g.Go(func() error {
+                       buf := make([]byte, 16)
+                       _, err := in.ReadAt(buf, int64(i)*plainBlockSize)
+
+                       return err
+               })
+       }
+       require.NoError(t, g.Wait())
+       elapsed := time.Since(start)
+
+       // Serial execution (for example, holding cacheMu across ReadAt) would 
take
+       // roughly numBlocks*delay. True concurrency keeps it close to a single
+       // delay; the threshold below is generous to stay robust on slow or 
loaded
+       // CI machines while still failing on a serialized implementation.
+       assert.Less(t, elapsed, numBlocks/2*delay, "concurrent ReadAt calls for 
distinct blocks must overlap, not serialize")
+}

Reply via email to