Reject a metadata database truncated to the age header and nonce (closes #152)
check / check (pull_request) Successful in 2m35s

An object holding just the age header and its 16-byte nonce decrypts
without error: the truncated read surfaces as io.ErrUnexpectedEOF at the
age layer, which the zstd decoder maps to a clean EOF at frame start.
blobgen then reported zero bytes and no error, so a truncated stream was
indistinguishable from a valid empty one, and the metadata database
export slipped through — restore built a fresh schema on the empty file
and reported success.

blobgen.Reader.Read now, on EOF, reads once more from the age reader and
surfaces io.ErrUnexpectedEOF unless that read is (0, io.EOF), the state a
genuine end leaves. downloadSnapshotDB additionally rejects a zero-length
decrypted database before any schema is built.

Model: opus-4-8
This commit is contained in:
2026-09-22 15:51:13 +00:00
parent 1244c9e48d
commit cf0f08586d
5 changed files with 214 additions and 0 deletions
+17
View File
@@ -2,6 +2,7 @@ package blobgen
import (
"crypto/sha256"
"errors"
"fmt"
"hash"
"io"
@@ -56,6 +57,22 @@ func (r *Reader) Read(p []byte) (int, error) {
n, err := r.teeReader.Read(p)
r.bytesRead += int64(n)
// When the ciphertext is cut right after the age header plus its
// 16-byte nonce, the age reader's first read fails with
// io.ErrUnexpectedEOF, and the zstd decoder maps that to a clean
// io.EOF at frame start. That makes a truncated stream look like a
// valid empty one. Distinguish the two: on EOF, read once more from
// the age reader. A genuine end leaves it at (0, io.EOF); a truncated
// stream leaves its stored io.ErrUnexpectedEOF, which we surface.
if errors.Is(err, io.EOF) {
var probe [1]byte
m, ageErr := r.decryptor.Read(probe[:])
if m != 0 || !errors.Is(ageErr, io.EOF) {
return n, io.ErrUnexpectedEOF
}
}
return n, err
}