diff --git a/unixfs/authentication_reader.go b/unixfs/authentication_reader.go index f73fb70..ae78bf3 100644 --- a/unixfs/authentication_reader.go +++ b/unixfs/authentication_reader.go @@ -97,25 +97,33 @@ func (r *verifiedReader) readTypedMetadata(ctx context.Context, root cid.Cid) (* } return verified, meta, nil } -func (r *verifiedReader) readChunkMetadata(ctx context.Context, meta engine.Metadata) (unixfsmodel.ChunkMetadata, error) { +func (r *verifiedReader) readChunkMetadata(ctx context.Context, meta engine.Metadata, body []byte) (unixfsmodel.ChunkMetadata, []byte, error) { if !meta.PayloadCID.Defined() { - return unixfsmodel.ChunkMetadata{}, fmt.Errorf("missing file chunk metadata") + return unixfsmodel.ChunkMetadata{}, nil, fmt.Errorf("missing file chunk metadata") } - hash, err := mh.Decode(meta.PayloadCID.Hash()) - if err != nil { - return unixfsmodel.ChunkMetadata{}, err - } - body := hash.Digest - if hash.Code != mh.IDENTITY { - body, err = r.getBoundBlock(ctx, meta.PayloadCID) + if body == nil { + hash, err := mh.Decode(meta.PayloadCID.Hash()) if err != nil { - return unixfsmodel.ChunkMetadata{}, err + return unixfsmodel.ChunkMetadata{}, nil, err } + body = hash.Digest + if hash.Code != mh.IDENTITY { + body, err = r.getBoundBlock(ctx, meta.PayloadCID) + if err != nil { + return unixfsmodel.ChunkMetadata{}, nil, err + } + } + } + // Retained bytes must still match the authenticated CID and count; decoded + // fields from a prior Stat are not authoritative. + geometry, err := unixfsmodel.ParseChunkMetadata(meta.PayloadCID, body, meta.Count) + if err != nil { + return unixfsmodel.ChunkMetadata{}, nil, err } - return unixfsmodel.ParseChunkMetadata(meta.PayloadCID, body, meta.Count) + return geometry, body, nil } -func (r *verifiedReader) readTypedRange(ctx context.Context, root cid.Cid, start uint64, length *uint64, metadata *protocol.AuthenticationVerification) (*ReadResult, error) { +func (r *verifiedReader) readTypedRange(ctx context.Context, root cid.Cid, start uint64, length *uint64, metadata *protocol.AuthenticationVerification, metadataBody []byte) (*ReadResult, error) { var meta engine.Metadata var err error if metadata == nil { @@ -137,7 +145,7 @@ func (r *verifiedReader) readTypedRange(ctx context.Context, root cid.Cid, start if err != nil { return nil, err } - geometry, err := r.readChunkMetadata(ctx, meta) + geometry, _, err := r.readChunkMetadata(ctx, meta, metadataBody) if err != nil { return nil, err } diff --git a/unixfs/authentication_reader_internal_test.go b/unixfs/authentication_reader_internal_test.go new file mode 100644 index 0000000..c5e33df --- /dev/null +++ b/unixfs/authentication_reader_internal_test.go @@ -0,0 +1,53 @@ +package unixfs + +import ( + "bytes" + "strings" + "testing" + + unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" + "github.com/dewebprotocol/malt-core/engine" + cid "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" +) + +func TestReadChunkMetadataRevalidatesRetainedBytes(t *testing.T) { + body, err := unixfsmodel.EncodeChunkMetadata(128, 16, 8) + if err != nil { + t.Fatal(err) + } + key, err := (cid.Prefix{Version: 1, Codec: cid.Raw, MhType: mh.SHA2_256, MhLength: -1}).Sum(body) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + body []byte + count uint64 + wantErr string + }{ + {name: "valid", body: body, count: 8}, + {name: "changed-bytes", body: bytes.Replace(body, []byte(`"128"`), []byte(`"127"`), 1), count: 8, wantErr: "payload CID"}, + {name: "changed-count", body: body, count: 9, wantErr: "authenticated count"}, + {name: "empty-bytes", body: []byte{}, count: 8, wantErr: "payload CID"}, + } { + t.Run(tc.name, func(t *testing.T) { + // Reuse must work without any CAS getter, including when retained + // evidence is invalid: reject it instead of fetching a replacement. + reader := &verifiedReader{} + geometry, retained, err := reader.readChunkMetadata(t.Context(), engine.Metadata{PayloadCID: key, Count: tc.count}, tc.body) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("retained metadata error = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if geometry.TotalSize != 128 || geometry.ChunkSize != 16 || !bytes.Equal(retained, body) { + t.Fatalf("retained metadata changed: %#v, %q", geometry, retained) + } + }) + } +} diff --git a/unixfs/verified_facade.go b/unixfs/verified_facade.go index 3d353bf..c23b566 100644 --- a/unixfs/verified_facade.go +++ b/unixfs/verified_facade.go @@ -62,6 +62,7 @@ type Stat struct { PayloadBinding *Resolution `json:"payload_binding,omitempty"` rawBody []byte rawBodyLoaded bool + chunkMetadataBody []byte } // ReadResult contains only bytes that have been bound to locally verified @@ -438,13 +439,14 @@ func (r *verifiedReader) Stat(ctx context.Context, trustedRoot cid.Cid, rawPath if err != nil { return nil, err } - geometry, err := r.readChunkMetadata(ctx, meta) + geometry, body, err := r.readChunkMetadata(ctx, meta, nil) if err != nil { return nil, err } stat.Size = geometry.TotalSize stat.ChunkSize = geometry.ChunkSize stat.AuthenticationMetadata = proof + stat.chunkMetadataBody = body case "raw": body, err := r.getBoundBlock(ctx, stat.Payload) if err != nil { @@ -595,7 +597,7 @@ func (r *verifiedReader) readStatFile(ctx context.Context, stat *Stat, offset ui } switch stat.PayloadKind { case "positional": - result, err := r.readTypedRange(ctx, stat.Payload, offset, length, stat.AuthenticationMetadata) + result, err := r.readTypedRange(ctx, stat.Payload, offset, length, stat.AuthenticationMetadata, stat.chunkMetadataBody) if err != nil { return nil, err } @@ -643,7 +645,7 @@ func (r *verifiedReader) ReadPositionalPayloadRange(ctx context.Context, trusted if parseErr != nil || descriptor.Layout != maltcid.Positional { return nil, fmt.Errorf("%w: target %s is not a positional Root", ErrNotFile, trustedPositionalRoot) } - return r.readTypedRange(ctx, trustedPositionalRoot, offset, &length, nil) + return r.readTypedRange(ctx, trustedPositionalRoot, offset, &length, nil, nil) } func (r *verifiedReader) getBoundBlock(ctx context.Context, key cid.Cid) ([]byte, error) { diff --git a/unixfs/verified_facade_test.go b/unixfs/verified_facade_test.go index 5b322f1..2a09d5d 100644 --- a/unixfs/verified_facade_test.go +++ b/unixfs/verified_facade_test.go @@ -546,6 +546,61 @@ func TestVerifiedReaderRejectsUntypedDirectoryManifests(t *testing.T) { } } +func TestVerifiedReaderFetchesChunkMetadataOnce(t *testing.T) { + remote := newRealRemote(t) + body := bytes.Repeat([]byte("0123456789abcdef"), 8) + root := materializeTree(t, remote, map[string][]byte{"file.bin": body}, 16) + reader, err := unixfs.NewReader(unixfs.ReaderOptions{Remote: remote, Blocks: remote}) + if err != nil { + t.Fatal(err) + } + stat, err := reader.Stat(t.Context(), root, "file.bin") + if err != nil { + t.Fatal(err) + } + metadata, err := stat.AuthenticationMetadata.Result.Binding.Proof.RootMetadata() + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + full bool + offset uint64 + length uint64 + want []byte + }{ + {name: "full", full: true, want: body}, + {name: "one-byte", length: 1, want: body[:1]}, + {name: "cross-chunk", offset: 15, length: 18, want: body[15:33]}, + {name: "empty-range", offset: 15}, + {name: "past-end", offset: uint64(len(body)) + 1, length: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := &countingBlocks{inner: remote, gets: make(map[string]int)} + reader, err := unixfs.NewReader(unixfs.ReaderOptions{Remote: remote, Blocks: blocks}) + if err != nil { + t.Fatal(err) + } + var result *unixfs.ReadResult + if tc.full { + result, err = reader.ReadFile(t.Context(), root, "file.bin") + } else { + result, err = reader.ReadFileRange(t.Context(), root, "file.bin", tc.offset, tc.length) + } + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(result.Body, tc.want) || result.TotalSize != uint64(len(body)) || result.ChunkSize != 16 { + t.Fatalf("read differs from authenticated file: %#v", result) + } + if got := blocks.gets[metadata.PayloadCID.KeyString()]; got != 1 { + t.Fatalf("chunk metadata fetched %d times, want 1", got) + } + }) + } +} + func TestVerifiedReaderFetchesEachAuthenticatedRangeBlockOnce(t *testing.T) { remote := newRealRemote(t) chunk := []byte("0123456789abcdef")