Skip to content
56 changes: 56 additions & 0 deletions internal/snapshot/archive/fsmetadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ package archive

import (
"archive/tar"
"context"
"errors"
"fmt"
"io"
"math"
"strconv"
"strings"
)
Expand Down Expand Up @@ -166,6 +169,59 @@ func (m FSMetadata) validate() error {
return nil
}

// SumTarRawSizes returns the sum of RawSize across every regular-file entry in the tar
// stream r, read via header-only seeks (tar.Reader skips each entry's body with Seek when
// r implements io.Seeker, never copying payload bytes). It is used to compute the exact
// byte total a filesystem volume's data.tar will produce on import, without a second full
// decode pass. ctx is checked once per entry so a large tar stays cancellable.
func SumTarRawSizes(ctx context.Context, r io.Reader) (int64, error) {
tr := tar.NewReader(r)

var total int64

for {
if err := ctx.Err(); err != nil {
return 0, err
}

hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}

if err != nil {
return 0, fmt.Errorf("read tar entry: %w", err)
}

if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != 0 {
continue
}

metadata, err := ParseFSMetadata(hdr)
if err != nil {
return 0, fmt.Errorf("entry %q: %w", hdr.Name, err)
}

total, err = addRawSize(total, metadata.RawSize)
if err != nil {
return 0, fmt.Errorf("entry %q: %w", hdr.Name, err)
}
}

return total, nil
}

// addRawSize adds size to total, failing rather than silently wrapping when the sum would
// overflow int64 — the same overflow-safety contract snapimport's own raw-size accounting
// (addRawSize) applies to its running upload total.
func addRawSize(total, size int64) (int64, error) {
if size > math.MaxInt64-total {
return 0, fmt.Errorf("raw-size total overflows int64")
}

return total + size, nil
}

func validateFSOriginalPath(originalPath string) error {
if originalPath == "" {
return fmt.Errorf("%w: original path is empty", ErrInvalidFSMetadata)
Expand Down
147 changes: 147 additions & 0 deletions internal/snapshot/archive/fsmetadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package archive
import (
"archive/tar"
"bytes"
"context"
"errors"
"io"
"os"
Expand Down Expand Up @@ -167,6 +168,152 @@ func TestComputeNodeChecksum_CoversFSPAXMetadata(t *testing.T) {
}
}

// TestSumTarRawSizes covers SumTarRawSizes: sums only regular-entry PAX raw sizes, ignores
// directory/symlink entries, and propagates ParseFSMetadata's fail-closed errors.
func TestSumTarRawSizes(t *testing.T) {
t.Parallel()

t.Run("success: empty tar sums to zero", func(t *testing.T) {
t.Parallel()

var buf bytes.Buffer

tw := tar.NewWriter(&buf)
if err := tw.Close(); err != nil {
t.Fatalf("close tar writer: %v", err)
}

total, err := SumTarRawSizes(context.Background(), bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("SumTarRawSizes: %v", err)
}

if total != 0 {
t.Errorf("total = %d, want 0", total)
}
})

t.Run("success: sums regular entries, ignores directory and symlink entries", func(t *testing.T) {
t.Parallel()

var buf bytes.Buffer

tw := tar.NewWriter(&buf)

if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: "dir/", Mode: 0o755}); err != nil {
t.Fatalf("write directory header: %v", err)
}

if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeSymlink, Name: "link", Linkname: "dir/first", Mode: 0o777}); err != nil {
t.Fatalf("write symlink header: %v", err)
}

writeRegularPAXEntry(t, tw, "dir/first", "none", 10)
writeRegularPAXEntry(t, tw, "second", "zstd", 25)

if err := tw.Close(); err != nil {
t.Fatalf("close tar writer: %v", err)
}

total, err := SumTarRawSizes(context.Background(), bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("SumTarRawSizes: %v", err)
}

if want := int64(10 + 25); total != want {
t.Errorf("total = %d, want %d", total, want)
}
})

t.Run("error: regular entry missing required PAX metadata", func(t *testing.T) {
t.Parallel()

var buf bytes.Buffer

tw := tar.NewWriter(&buf)

// A regular entry with no PAX records at all: ParseFSMetadata must reject it,
// and SumTarRawSizes must propagate that failure rather than skip the entry.
if err := tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeReg,
Name: "broken.txt",
Mode: 0o600,
Size: 3,
}); err != nil {
t.Fatalf("write header: %v", err)
}

if _, err := io.WriteString(tw, "abc"); err != nil {
t.Fatalf("write body: %v", err)
}

if err := tw.Close(); err != nil {
t.Fatalf("close tar writer: %v", err)
}

_, err := SumTarRawSizes(context.Background(), bytes.NewReader(buf.Bytes()))
if !errors.Is(err, ErrInvalidFSMetadata) {
t.Fatalf("SumTarRawSizes error = %v, want wrapping ErrInvalidFSMetadata", err)
}
})

t.Run("error: context canceled before completion", func(t *testing.T) {
t.Parallel()

var buf bytes.Buffer

tw := tar.NewWriter(&buf)
writeRegularPAXEntry(t, tw, "first", "none", 5)

if err := tw.Close(); err != nil {
t.Fatalf("close tar writer: %v", err)
}

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := SumTarRawSizes(ctx, bytes.NewReader(buf.Bytes()))
if !errors.Is(err, context.Canceled) {
t.Fatalf("SumTarRawSizes error = %v, want context.Canceled", err)
}
})
}

// writeRegularPAXEntry writes one well-formed regular PAX entry of rawSize plaintext bytes.
// Body is always written raw (codec name is just metadata) so stored size always == rawSize.
func writeRegularPAXEntry(t *testing.T, tw *tar.Writer, originalPath, codec string, rawSize int64) {
t.Helper()

metadata, err := NewFSMetadata(codec, originalPath, rawSize)
if err != nil {
t.Fatalf("NewFSMetadata: %v", err)
}

storedPath, err := metadata.StoredPath()
if err != nil {
t.Fatalf("StoredPath: %v", err)
}

hdr := &tar.Header{
Format: tar.FormatPAX,
Typeflag: tar.TypeReg,
Name: storedPath,
Mode: 0o600,
Size: rawSize,
PAXRecords: metadata.PAXRecords(),
}

// SumTarRawSizes never decodes the body, so any bytes of the declared Size work here;
// content correctness for a given codec is exercised elsewhere (fsmetadata round trip).
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader: %v", err)
}

if _, err := tw.Write(make([]byte, rawSize)); err != nil {
t.Fatalf("Write: %v", err)
}
}

func writeTestMetadataTar(t *testing.T, path string, rawSize int64) {
t.Helper()

Expand Down
29 changes: 22 additions & 7 deletions internal/snapshot/archive/snapshot_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ const (
// SnapshotFormatVersionLegacy identifies archives written before explicit envelope versioning.
// Version zero is accepted only through an explicit unauthenticated compatibility option.
SnapshotFormatVersionLegacy = 0
// SnapshotFormatVersionCurrent is written by every snapshot.yaml marshal. Version 2 adds
// the mandatory authenticated direct-child commitment (ChildrenChecksum).
SnapshotFormatVersionCurrent = 2
// SnapshotFormatVersionAuthenticatedChildren adds the mandatory ChildrenChecksum. An archive
// at this version has no RawSizeBytes/StoredSizeBytes; readers measure the payload instead.
SnapshotFormatVersionAuthenticatedChildren = 2
// SnapshotFormatVersionPayloadSizes adds VolumeInfo.RawSizeBytes/StoredSizeBytes, the
// measured on-disk payload footprint (as opposed to Size's nominal restoreSize).
SnapshotFormatVersionPayloadSizes = 3
// SnapshotFormatVersionCurrent is written by every snapshot.yaml marshal.
SnapshotFormatVersionCurrent = SnapshotFormatVersionPayloadSizes
)

// sha256HexLen is the length of a hex-encoded SHA-256 digest (32 bytes → 64 hex chars).
Expand Down Expand Up @@ -211,7 +216,7 @@ func validateSnapshotEnvelope(sy SnapshotYAML, options SnapshotYAMLReadOptions)
}

return validateChildrenChecksumPresent(sy)
case SnapshotFormatVersionCurrent:
case SnapshotFormatVersionAuthenticatedChildren, SnapshotFormatVersionCurrent:
default:
return fmt.Errorf("%d: %w", sy.FormatVersion, ErrUnsupportedSnapshotFormat)
}
Expand Down Expand Up @@ -319,10 +324,20 @@ type VolumeInfo struct {
// StorageClassName records the source StorageClass of the captured volume. On re-import it
// is sent as the PopulateData DataImport's spec.storageParams.storageClassName (required).
StorageClassName string `json:"storageClassName,omitempty"`
// Size records the real allocated size of the captured volume (e.g. "10Gi"), taken from
// VolumeSnapshotContent.status.restoreSize. On re-import it is sent as the PopulateData
// DataImport's spec.storageParams.size (required).
// Size records the NOMINAL captured-volume quantity (e.g. "10Gi"), taken from
// VolumeSnapshotContent.status.restoreSize. Used only to size the scratch volume on
// re-import (spec.storageParams.size, required) — NOT the payload's real byte size, since
// a thin-provisioning backend can round the device up from it. See RawSizeBytes/StoredSizeBytes.
Size string `json:"size,omitempty"`
// RawSizeBytes is the exact decoded byte count of the captured payload: for Block, the
// decoded length of data.bin[.<ext>]; for Filesystem, the sum of data.tar's regular-entry
// raw sizes. Unlike Size, this can exceed the nominal PVC quantity (thin-provisioning
// round-up). Recorded from SnapshotFormatVersionPayloadSizes onward; zero on an older
// archive means "not recorded", not "empty".
RawSizeBytes int64 `json:"rawSizeBytes,omitempty"`
// StoredSizeBytes is the on-disk size of the payload artifact (data.bin[.<ext>] or
// data.tar). Informational plus a corruption preflight; same presence rules as RawSizeBytes.
StoredSizeBytes int64 `json:"storedSizeBytes,omitempty"`
}

// NodeChecksum is a locally-computed integrity digest. SnapshotYAML.Checksum covers the node's
Expand Down
Loading
Loading