rustfs/rustfs · error · io::Error

unexpected EOF while reading DARE ciphertext

Error message

unexpected EOF while reading DARE ciphertext

What it means

While DecryptReader fills ciphertext_buf for the current package, the underlying AsyncRead returned Ok with 0 filled bytes (a clean EOF) before the full package body (payload + 16-byte tag) arrived. The stream ends in the middle of a package whose header promised more bytes. It differs from 'DARE stream truncated before a finalized package', which fires at a package boundary when no final-flagged package was ever seen.

Source

Thrown at crates/rio-v2/src/encrypt_reader.rs:526

                        "non-final DARE package must carry a full 64KiB payload: cipher={}, payload_len={}, sequence_number={}, header={:02x?}",
                        header[1], payload_len, *this.sequence_number, header
                    ),
                )));
            }
            if this.ciphertext_buf.len() < package_len {
                this.ciphertext_buf.resize(package_len, 0);
            }
            *this.ciphertext_len = package_len;
            *this.ciphertext_read = 0;

            while *this.ciphertext_read < *this.ciphertext_len {
                let mut read_buf = ReadBuf::new(&mut this.ciphertext_buf[*this.ciphertext_read..*this.ciphertext_len]);
                match this.inner.as_mut().poll_read(cx, &mut read_buf) {
                    Poll::Pending => return Poll::Pending,
                    Poll::Ready(Ok(())) => {
                        let n = read_buf.filled().len();
                        if n == 0 {
                            return Poll::Ready(Err(io::Error::new(
                                io::ErrorKind::UnexpectedEof,
                                "unexpected EOF while reading DARE ciphertext",
                            )));
                        }
                        *this.ciphertext_read += n;
                    }
                    Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
                }
            }

            match open_dare_package(
                *this.key,
                this.cipher,
                *this.sequence_number,
                *this.expected_base_nonce,
                *header,
                &this.ciphertext_buf[..*this.ciphertext_len],
                this.ref_nonce,

View on GitHub (pinned to 9e6e02ea09)

Solutions

  1. Compare the bytes the inner reader actually delivered against the object size in metadata; a mismatch means the backend view is stale or truncated — re-resolve the object and open a fresh stream.
  2. If the cut is transient (connection drop), resume: re-open the reader from the last full package boundary (sequence_number = ciphertext_offset / 65552); decrypted bytes before the cut remain valid.
  3. For uploads you control, ensure the encrypting writer flushes the final package before the object is completed.
  4. If truncation persists across re-reads, investigate backend/disk health rather than retrying.

Example fix

// before: a cut connection kills the whole read
let mut out = Vec::new();
reader.read_to_end(&mut out).await?;

// after: on UnexpectedEof, reopen and resume from the last full package boundary
match reader.read_to_end(&mut out).await {
    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
        let seq = (ciphertext_pos / DARE_PACKAGE_SIZE) as u32;
        reader = DecryptReader::new_with_sequence(reopen_from(seq), key, nonce, seq);
        // continue reading; the decrypted prefix stays valid
    }
    result => result?,
}
Defensive patterns

Strategy: retry

Validate before calling

// Before decrypting, confirm the stream can plausibly hold the framed packages
let min_len = full_packages * 65_552 + if has_final { 16 + last_payload + 16 } else { 0 };
if actual_object_len < min_len {
    return Err(truncated_metadata(actual_object_len, min_len));
}

Try / catch

match reader.read(&mut buf).await {
    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
        // one bounded retry from the last full package boundary, then fail
        let seq = (ciphertext_pos / DARE_PACKAGE_SIZE) as u32;
        retry_once_with(DecryptReader::new_with_sequence(reopen(), key, nonce, seq)).await
    }
    other => other,
}

Prevention

When it happens

Trigger: Object content shorter than its recorded size (truncated upload, incomplete multipart complete); a network layer cutting the body early while the reader still expects payload + tag bytes; an inner range reader whose byte range ends before the current package completes; a writer that never flushed the trailing package.

Common situations: Proxy, CDN, or load balancer truncating GET responses; partial writes after disk-full; missing erasure-coded shards yielding short reads; metadata size disagreeing with actual object length after a failed overwrite.

Related errors


AI-assisted analysis of rustfs/rustfs@9e6e02ea09 (2026-08-16). Data as JSON: /api/errors/521e67b35c7e9bc6. Report an issue: GitHub.