From fb5e172950c236296a71adb9ba47131402930cd2 Mon Sep 17 00:00:00 2001 From: BernardLowe Date: Sat, 10 Oct 2026 08:20:23 +0800 Subject: [PATCH] refactor: bind file geometry through payload CIDs Move Positional chunk geometry into canonical CID-bound application JSON, translate byte ranges to element-index ranges, and include root metadata in write-plan dependencies. Pin the exact Core root-only metadata implementation. Co-authored-by: Codex --- application/add/add_test.go | 2 +- application/add/authentication.go | 3 +- docs/authentication-inputs.md | 6 +- docs/positional-chunk-metadata.md | 34 +++++++ docs/typed-authentication-migration.md | 18 ++-- go.mod | 2 +- go.sum | 4 +- .../evaluation/authenticationgraph/graph.go | 27 ++++-- .../authenticationgraph/graph_test.go | 2 +- .../evaluation/rq2fixture/authentication.go | 7 +- internal/evaluation/rq2fixture/oracle.go | 22 +++-- internal/evaluation/rq2write/project.go | 19 +++- unixfs/authentication_adapter.go | 6 +- unixfs/authentication_reader.go | 44 ++++++--- unixfs/authentication_test.go | 7 +- unixfs/encrypted/builder.go | 17 +++- unixfs/encrypted/snapshot.go | 4 +- unixfs/measured_payload.go | 25 +++-- unixfs/model/chunk_metadata.go | 95 +++++++++++++++++++ unixfs/model/chunk_metadata_test.go | 40 ++++++++ unixfs/planner/writers.go | 2 +- unixfs/verified_facade.go | 14 ++- unixfs/verified_facade_test.go | 12 +-- writeplan/plan.go | 15 ++- writeplan/plan_test.go | 28 ++++++ writeplan/staging.go | 4 +- 26 files changed, 380 insertions(+), 79 deletions(-) create mode 100644 docs/positional-chunk-metadata.md create mode 100644 unixfs/model/chunk_metadata.go create mode 100644 unixfs/model/chunk_metadata_test.go diff --git a/application/add/add_test.go b/application/add/add_test.go index 4ce9dda..4aacc09 100644 --- a/application/add/add_test.go +++ b/application/add/add_test.go @@ -462,7 +462,7 @@ func (c *countingAddCAS) Get(ctx context.Context, key cid.Cid) ([]byte, error) { return c.inner.Get(ctx, key) } -func (g *countingAddGateway) CreateMeasuredPayload(context.Context, []cid.Cid, uint64, uint64) (cid.Cid, error) { +func (g *countingAddGateway) CreatePositionalPayload(context.Context, []cid.Cid, cid.Cid) (cid.Cid, error) { g.calls++ return cid.Undef, os.ErrInvalid } diff --git a/application/add/authentication.go b/application/add/authentication.go index e2f68bc..0808296 100644 --- a/application/add/authentication.go +++ b/application/add/authentication.go @@ -3,6 +3,7 @@ package add import ( "context" "fmt" + "github.com/dewebprotocol/malt-client/unixfs" "github.com/dewebprotocol/malt-core/maltcid" ) @@ -10,7 +11,7 @@ import ( // projectionWriter belongs to the UnixFS layout compiler, not the transport. type projectionWriter interface { unixfs.StagedRootWriter - unixfs.MeasuredPayloadWriter + unixfs.PositionalPayloadWriter } func newProjectionWriter(ctx context.Context, remote Materializer, layout unixfs.LayoutKind) (*unixfs.AuthenticationAdapter, error) { diff --git a/docs/authentication-inputs.md b/docs/authentication-inputs.md index 3e4e59a..56f2f4b 100644 --- a/docs/authentication-inputs.md +++ b/docs/authentication-inputs.md @@ -19,8 +19,10 @@ stays in `unixfs/model`; AuthenticationSteps produces explicit component labels. A full-path index instead submits one opaque full-path label. A Root's AA, not slash syntax inferred by the generic engine, chooses the conversion rule. The current UnixFS planner builds Prefix and Positional V0 Roots directly -through typed authentication. No semantic Map/List adapter remains. Only Prefix supports system bindings; Positional -metadata is structural. New low-level clients can choose native 32-byte keys +through typed authentication. No semantic Map/List adapter remains. Prefix +payload labels are ordinary application bindings. Positional root metadata +contains the sequence count and an optional opaque payload CID; the application +verifies and decodes the referenced document. New low-level clients can choose native 32-byte keys or a registered label rule explicitly. Writeback preserves the base Root's layout, AA and exact VC profile. Historical diff --git a/docs/positional-chunk-metadata.md b/docs/positional-chunk-metadata.md new file mode 100644 index 0000000..583f500 --- /dev/null +++ b/docs/positional-chunk-metadata.md @@ -0,0 +1,34 @@ +# Positional chunk metadata + +A Positional Root authenticates its element count and an optional payload CID. +Core does not interpret file sizes, chunk widths or application JSON. UnixFS +requires a payload CID for chunked files and uses the following canonical UTF-8 +JSON document, encoded as a raw CID: + +```json +{"profile":"malt.unixfs.chunks/1","chunk_size":"4","total_size":"8"} +``` + +Fields occur in the order shown, without whitespace or extra fields. Sizes are +canonical unsigned decimal strings. `chunk_size` must be positive. The count +already authenticated by Core must equal zero for an empty file, otherwise +`1 + (total_size - 1) / chunk_size`. The document does not duplicate count. +`unixfs/model` owns encoding and validation. Identity CIDs may inline the same +bytes; other CIDs are fetched through the injected block reader and hashed +before decoding. + +Writers upload this document with the chunk blocks, verify the returned CID, +and pass that CID to `CreatePositionalPayload`. The document consumes no data +index. Readers authenticate the root metadata, verify the JSON CID and count, +translate the requested half-open byte interval into a half-open element-index +interval, and verify that range against the same Root. Every returned chunk is +CID-checked, length-checked, then sliced locally. Empty files retain their +metadata document. Encrypted files use ciphertext sizes in this document and +check them against their authenticated encryption manifest. + +A payload CID is also a graph dependency: write plans retain its block or Root, +place referenced candidates before their parents, and include it in dependency +closure. It does not accept a trusted Root or authorize publication. + +The structural layout and proof format are defined only by the +[Core specification](../../malt-core/docs/spec/authentication-contracts.md). diff --git a/docs/typed-authentication-migration.md b/docs/typed-authentication-migration.md index c1993a4..c38697a 100644 --- a/docs/typed-authentication-migration.md +++ b/docs/typed-authentication-migration.md @@ -2,9 +2,9 @@ The runtime uses the sole current Core path: `derivation`, `auth/tree`, `engine`, explicit traversal, and `sdk/authentication`. Native HTTP queries -use `malt.authentication/3`; candidates use `malt.authentication/2`. Ordered -writes use `malt.authentication-batch/1` and exact -`malt.authentication-receipt/1` checks. No old Resolve/Read, Map-proof, +use `malt.authentication/5`; candidates use `malt.authentication/4`. Ordered +writes use `malt.authentication-batch/2` and exact +`malt.authentication-receipt/2` checks. No old Resolve/Read, Map-proof, UpdateView, semantic intent, client-root bundle, or WASM fallback is used. ## Reads and application layouts @@ -16,8 +16,11 @@ target remains a Prefix Root, a content read explicitly authenticates its ordinary `@payload` label. All directory layouts use public SHA256 derivation (profile 4); ReaderOptions and filesystem service options carry the application layout. Managed mounts obtain it from the selected Bucket metadata. -Positional chunk Roots expose authenticated geometry and byte ranges, with no -system payload binding. +Positional Roots authenticate `count` and an optional `payload_cid` in root +slot zero. Non-root vectors use every slot. UnixFS stores byte geometry in +CID-bound application JSON, verifies that document against the authenticated +count, and converts byte ranges to Core element-index intervals. See +[chunk metadata](positional-chunk-metadata.md). `unixfs.AuthenticationAdapter` computes Roots locally and checks exact remote candidate identity. The verified reader checks each caller-selected typed @@ -53,7 +56,8 @@ new versioned measurement fields. Source workspace validation is not release publication. The runtime module namespace/tag gate and exact downstream release pins remain separate from this source migration. -Native builds pin published Core `v0.0.10-rc.3` at commit -`6f16b13f3abe9cbd0992e83e1457663935e5a72f` for independent module builds. +Native builds pin Core commit `949fb4e690c81d7ff1b5141ec95ddf7f7a926460` +through its exact Go pseudo-version for independent module builds. This source +integration is not a runtime or browser release. New roots use the remote's advertised backend as an untrusted creation hint; updates preserve the existing Root descriptor. diff --git a/go.mod b/go.mod index 7e56802..7a06ddd 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/Microsoft/go-winio v0.6.2 github.com/crackcomm/go-gitignore v0.0.0-20241020182519-7843d2ba8fdf - github.com/dewebprotocol/malt-core v0.0.10-rc.4 + github.com/dewebprotocol/malt-core v0.0.10-rc.4.0.20261009112445-949fb4e690c8 github.com/hanwen/go-fuse/v2 v2.11.0 github.com/ipfs/boxo v0.41.0 github.com/ipfs/go-block-format v0.2.4 diff --git a/go.sum b/go.sum index c5ef01d..5bf17e4 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/dewebprotocol/malt-core v0.0.10-rc.4 h1:BpA7vH0LwKTmipTlAUu97CniBXTxBT9f5AQWV8UFbmY= -github.com/dewebprotocol/malt-core v0.0.10-rc.4/go.mod h1:o6L8K9vJp5hyrVo3fUtSsj0rvNpcboOcOb3nQwnR/gs= +github.com/dewebprotocol/malt-core v0.0.10-rc.4.0.20261009112445-949fb4e690c8 h1:Eqvk2/jmKg48S0My2/QYRG/3jc5sEEMFsW4iEAvHE3A= +github.com/dewebprotocol/malt-core v0.0.10-rc.4.0.20261009112445-949fb4e690c8/go.mod h1:o6L8K9vJp5hyrVo3fUtSsj0rvNpcboOcOb3nQwnR/gs= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= diff --git a/internal/evaluation/authenticationgraph/graph.go b/internal/evaluation/authenticationgraph/graph.go index 7f98f68..82d9017 100644 --- a/internal/evaluation/authenticationgraph/graph.go +++ b/internal/evaluation/authenticationgraph/graph.go @@ -153,12 +153,12 @@ func (s *Session) fetch(ctx context.Context, root cid.Cid) (*graph, LoadMetrics, if err != nil { return fmt.Errorf("authenticate candidate %s: %w", key, err) } - for _, entry := range candidate.State.Entries { - if _, _, err := maltcid.ParseRoot(entry.Target); err == nil { - if err := visit(entry.Target, depth+1); err != nil { + for _, target := range graphTargets(candidate.State) { + if _, _, err := maltcid.ParseRoot(target); err == nil { + if err := visit(target, depth+1); err != nil { return err } - heights[key] = max(heights[key], 1+heights[entry.Target.String()]) + heights[key] = max(heights[key], 1+heights[target.String()]) } } next.objects[key] = object{writer: writer, state: cloneState(candidate.State), bytes: size} @@ -326,12 +326,12 @@ func (e *Edit) graph(ctx context.Context, root cid.Cid) (*graph, error) { entries += uint64(len(o.state.Entries)) size += o.bytes visiting[key] = true - for _, entry := range o.state.Entries { - if _, _, err := maltcid.ParseRoot(entry.Target); err == nil { - if err := visit(entry.Target, depth+1); err != nil { + for _, target := range graphTargets(o.state) { + if _, _, err := maltcid.ParseRoot(target); err == nil { + if err := visit(target, depth+1); err != nil { return err } - heights[key] = max(heights[key], 1+heights[entry.Target.String()]) + heights[key] = max(heights[key], 1+heights[target.String()]) } } next.objects[key] = o @@ -391,3 +391,14 @@ func cloneState(state engine.State) engine.State { return state } func elapsed(started time.Time) uint64 { return uint64(max(time.Since(started), 0)) } + +func graphTargets(state engine.State) []cid.Cid { + targets := make([]cid.Cid, 0, len(state.Entries)+1) + if state.PayloadCID.Defined() { + targets = append(targets, state.PayloadCID) + } + for _, entry := range state.Entries { + targets = append(targets, entry.Target) + } + return targets +} diff --git a/internal/evaluation/authenticationgraph/graph_test.go b/internal/evaluation/authenticationgraph/graph_test.go index 7543861..b747cde 100644 --- a/internal/evaluation/authenticationgraph/graph_test.go +++ b/internal/evaluation/authenticationgraph/graph_test.go @@ -91,7 +91,7 @@ func graphFixture(t *testing.T) (*Session, *fixtureRemote, *countedScheme, cid.C } e := engine.New(registry) payload := raw(t, "first chunk") - child, err := authentication.Prepare(ctx, e, engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: maltcid.KZG4096}, Entries: []engine.Entry{{Label: coordinate.EncodeIndex(0), Target: payload}}, ChunkSize: 11, TotalSize: 11}) + child, err := authentication.Prepare(ctx, e, engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: maltcid.KZG4096}, Entries: []engine.Entry{{Label: coordinate.EncodeIndex(0), Target: payload}}}) if err != nil { t.Fatal(err) } diff --git a/internal/evaluation/rq2fixture/authentication.go b/internal/evaluation/rq2fixture/authentication.go index 9188cd5..0db5cf2 100644 --- a/internal/evaluation/rq2fixture/authentication.go +++ b/internal/evaluation/rq2fixture/authentication.go @@ -6,6 +6,7 @@ import ( clientcas "github.com/dewebprotocol/malt-client/internal/cas" "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" + unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" "github.com/dewebprotocol/malt-core/auth/coordinate" "github.com/dewebprotocol/malt-core/derivation" "github.com/dewebprotocol/malt-core/engine" @@ -36,7 +37,11 @@ func (s *SourceDefinition) Candidates(ctx context.Context, e *engine.Engine, bac bindings = append(bindings, engine.Entry{Label: []byte(file.Path), Target: target}) } for _, file := range s.ListFiles { - state := engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: profile}, ChunkSize: file.ChunkSize, TotalSize: file.TotalSize, Entries: make([]engine.Entry, len(file.Chunks))} + state := engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: profile}, Entries: make([]engine.Entry, len(file.Chunks))} + state.PayloadCID, err = unixfsmodel.InlineChunkMetadata(file.TotalSize, file.ChunkSize, uint64(len(file.Chunks))) + if err != nil { + return nil, err + } for i, chunk := range file.Chunks { target, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: chunk.Bytes}) if err != nil { diff --git a/internal/evaluation/rq2fixture/oracle.go b/internal/evaluation/rq2fixture/oracle.go index 67ccbee..bc596c2 100644 --- a/internal/evaluation/rq2fixture/oracle.go +++ b/internal/evaluation/rq2fixture/oracle.go @@ -5,6 +5,7 @@ import ( clientcas "github.com/dewebprotocol/malt-client/internal/cas" "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" + unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" "github.com/dewebprotocol/malt-core/auth/coordinate" "github.com/dewebprotocol/malt-core/derivation" "github.com/dewebprotocol/malt-core/engine" @@ -32,7 +33,7 @@ func (f *Fixture) ValidateGraphAgainstSource(view authenticationgraph.View, back if err != nil { return err } - if root.Descriptor != descriptor || root.ChunkSize != 0 || root.TotalSize != 0 || len(root.Entries) != len(source) { + if root.Descriptor != descriptor || root.PayloadCID.Defined() || len(root.Entries) != len(source) { return fmt.Errorf("root metadata or binding count differs from source") } entries := make(map[string]engine.Entry, len(root.Entries)) @@ -71,7 +72,11 @@ func (f *Fixture) ValidateGraphAgainstSource(view authenticationgraph.View, back if err != nil { return err } - if object.Descriptor != descriptor || object.ChunkSize != file.ChunkSize || object.TotalSize != uint64(len(data)) { + geometry, err := unixfsmodel.ParseInlineChunkMetadata(object.PayloadCID, uint64(len(object.Entries))) + if err != nil { + return err + } + if object.Descriptor != descriptor || geometry.ChunkSize != file.ChunkSize || geometry.TotalSize != uint64(len(data)) { return fmt.Errorf("source file %q measured metadata does not match post-image", path) } if err := validateChunkEntries(object, data); err != nil { @@ -90,11 +95,12 @@ func (f *Fixture) ValidateGraphAgainstSource(view authenticationgraph.View, back return nil } func validateChunkEntries(state engine.State, data []byte) error { - if state.ChunkSize == 0 { - return fmt.Errorf("chunk size is zero") + geometry, err := unixfsmodel.ParseInlineChunkMetadata(state.PayloadCID, uint64(len(state.Entries))) + if err != nil { + return err } - count := uint64(len(data)) / state.ChunkSize - if uint64(len(data))%state.ChunkSize != 0 { + count := uint64(len(data)) / geometry.ChunkSize + if uint64(len(data))%geometry.ChunkSize != 0 { count++ } if uint64(len(state.Entries)) != count { @@ -109,8 +115,8 @@ func validateChunkEntries(state engine.State, data []byte) error { return fmt.Errorf("chunk input is duplicate, invalid or outside the measured sequence") } seen[index] = true - start := index * state.ChunkSize - end := start + min(uint64(len(data))-start, state.ChunkSize) + start := index * geometry.ChunkSize + end := start + min(uint64(len(data))-start, geometry.ChunkSize) expected, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: data[start:end]}) if err != nil || !entry.Target.Equals(expected) { return fmt.Errorf("chunk %d CID does not bind its source bytes", index) diff --git a/internal/evaluation/rq2write/project.go b/internal/evaluation/rq2write/project.go index ba2f5d3..4988a0d 100644 --- a/internal/evaluation/rq2write/project.go +++ b/internal/evaluation/rq2write/project.go @@ -11,6 +11,7 @@ import ( "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" "github.com/dewebprotocol/malt-client/internal/evaluation/rq2fixture" + unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" "github.com/dewebprotocol/malt-core/auth/coordinate" "github.com/dewebprotocol/malt-core/derivation" "github.com/dewebprotocol/malt-core/engine" @@ -41,26 +42,34 @@ func Apply(ctx context.Context, edit *authenticationgraph.Edit, root cid.Cid, op if err != nil { return cid.Undef, err } - if child.Descriptor.Layout != maltcid.Positional || child.Descriptor.DerivationProfile != uint8(derivation.Direct) || child.Descriptor.Profile != state.Descriptor.Profile || child.ChunkSize == 0 { + if child.Descriptor.Layout != maltcid.Positional || child.Descriptor.DerivationProfile != uint8(derivation.Direct) || child.Descriptor.Profile != state.Descriptor.Profile || !child.PayloadCID.Defined() { return cid.Undef, fmt.Errorf("file requires a measured Positional Root with the parent's VC profile") } if len(payloads) != 1 { return cid.Undef, fmt.Errorf("chunk operation requires one payload") } + geometry, err := unixfsmodel.ParseInlineChunkMetadata(child.PayloadCID, uint64(len(child.Entries))) + if err != nil { + return cid.Undef, err + } var delta authentication.Delta if operation.Kind == rq2fixture.KindListAppend { - if child.TotalSize%child.ChunkSize != 0 || operation.PayloadBytes != child.ChunkSize || child.TotalSize > math.MaxUint64-child.ChunkSize { + if geometry.TotalSize%geometry.ChunkSize != 0 || operation.PayloadBytes != geometry.ChunkSize || geometry.TotalSize > math.MaxUint64-geometry.ChunkSize { return cid.Undef, fmt.Errorf("append requires a full final chunk and one same-width new chunk") } count := uint64(len(child.Entries)) + 1 - total := child.TotalSize + child.ChunkSize - delta = authentication.Delta{Changes: []engine.Change{{Label: coordinate.EncodeIndex(count - 1), After: payloads[0]}}, Count: &count, TotalSize: &total} + total := geometry.TotalSize + geometry.ChunkSize + payload, err := unixfsmodel.InlineChunkMetadata(total, geometry.ChunkSize, count) + if err != nil { + return cid.Undef, err + } + delta = authentication.Delta{Changes: []engine.Change{{Label: coordinate.EncodeIndex(count - 1), After: payloads[0]}}, Count: &count, PayloadCID: &payload} } else { if operation.ListIndex == nil || *operation.ListIndex >= uint64(len(child.Entries)) { return cid.Undef, fmt.Errorf("replacement index is outside measured file") } index := *operation.ListIndex - size := min(child.ChunkSize, child.TotalSize-index*child.ChunkSize) + size := min(geometry.ChunkSize, geometry.TotalSize-index*geometry.ChunkSize) if operation.PayloadBytes != size { return cid.Undef, fmt.Errorf("replacement must preserve the authenticated chunk length") } diff --git a/unixfs/authentication_adapter.go b/unixfs/authentication_adapter.go index e0670a2..b2b8e00 100644 --- a/unixfs/authentication_adapter.go +++ b/unixfs/authentication_adapter.go @@ -20,7 +20,7 @@ import ( // AuthenticationAdapter compiles the selected UnixFS layout to typed ArcSets. // Directory payloads are system bindings; names are application labels. File -// block sequences carry measured structural metadata and no system payload. +// block sequences bind application metadata through an opaque root payload CID. // It computes candidates locally and checks the exact remote receipt. type AuthenticationAdapter struct { remote nodeapi.AuthenticationWriter @@ -124,8 +124,8 @@ func (a *AuthenticationAdapter) materialize(ctx context.Context, previous cid.Ci } return expected, nil } -func (a *AuthenticationAdapter) CreateMeasuredPayload(ctx context.Context, chunks []cid.Cid, total, chunkSize uint64) (cid.Cid, error) { - state := engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: a.profile}, ChunkSize: chunkSize, TotalSize: total, Entries: make([]engine.Entry, len(chunks))} +func (a *AuthenticationAdapter) CreatePositionalPayload(ctx context.Context, chunks []cid.Cid, payload cid.Cid) (cid.Cid, error) { + state := engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: a.profile}, PayloadCID: payload, Entries: make([]engine.Entry, len(chunks))} for i, target := range chunks { state.Entries[i] = engine.Entry{Label: coordinate.EncodeIndex(uint64(i)), Target: target} } diff --git a/unixfs/authentication_reader.go b/unixfs/authentication_reader.go index 741b6ca..f73fb70 100644 --- a/unixfs/authentication_reader.go +++ b/unixfs/authentication_reader.go @@ -6,11 +6,13 @@ import ( "math" "strings" + unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" "github.com/dewebprotocol/malt-core/auth/coordinate" "github.com/dewebprotocol/malt-core/engine" "github.com/dewebprotocol/malt-core/protocol" "github.com/dewebprotocol/malt-core/sdk/authentication" cid "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" ) func (r *verifiedReader) authenticate(ctx context.Context, q protocol.AuthenticationRequest) (*protocol.AuthenticationVerification, error) { @@ -90,11 +92,29 @@ func (r *verifiedReader) readTypedMetadata(ctx context.Context, root cid.Cid) (* if err != nil { return nil, meta, err } - if meta.ChunkSize == 0 { - return nil, meta, fmt.Errorf("UnixFS bytes require a measured Positional ArcSet") + if !meta.PayloadCID.Defined() { + return nil, meta, fmt.Errorf("UnixFS bytes require application chunk metadata") } return verified, meta, nil } +func (r *verifiedReader) readChunkMetadata(ctx context.Context, meta engine.Metadata) (unixfsmodel.ChunkMetadata, error) { + if !meta.PayloadCID.Defined() { + return unixfsmodel.ChunkMetadata{}, 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 err != nil { + return unixfsmodel.ChunkMetadata{}, err + } + } + return unixfsmodel.ParseChunkMetadata(meta.PayloadCID, body, meta.Count) +} + func (r *verifiedReader) readTypedRange(ctx context.Context, root cid.Cid, start uint64, length *uint64, metadata *protocol.AuthenticationVerification) (*ReadResult, error) { var meta engine.Metadata var err error @@ -117,19 +137,21 @@ func (r *verifiedReader) readTypedRange(ctx context.Context, root cid.Cid, start if err != nil { return nil, err } - if meta.ChunkSize == 0 { - return nil, fmt.Errorf("unmeasured sequence") + geometry, err := r.readChunkMetadata(ctx, meta) + if err != nil { + return nil, err } - result := &ReadResult{Target: root, Offset: start, TotalSize: meta.TotalSize, ChunkSize: meta.ChunkSize, Authentication: metadata} - if start >= meta.TotalSize || (length != nil && *length == 0) { + result := &ReadResult{Target: root, Offset: start, TotalSize: geometry.TotalSize, ChunkSize: geometry.ChunkSize, Authentication: metadata} + if start >= geometry.TotalSize || (length != nil && *length == 0) { result.End = start return result, nil } - end := meta.TotalSize + end := geometry.TotalSize if length != nil { end = min(end, saturatingAdd(start, *length)) } - q := protocol.AuthenticationRequest{Profile: protocol.AuthenticationPathProfile, Root: root.String(), Steps: [][]byte{}, Operation: "range", Start: &start, End: &end} + firstIndex, stopIndex := start/geometry.ChunkSize, (end-1)/geometry.ChunkSize+1 + q := protocol.AuthenticationRequest{Profile: protocol.AuthenticationPathProfile, Root: root.String(), Steps: [][]byte{}, Operation: "range", Start: &firstIndex, End: &stopIndex} verified, err := r.authenticate(ctx, q) if err != nil { return nil, err @@ -151,9 +173,9 @@ func (r *verifiedReader) readTypedRange(ctx context.Context, root cid.Cid, start } blocks[key] = data } - index := start/meta.ChunkSize + uint64(i) - offset := index * meta.ChunkSize - expected := min(meta.ChunkSize, meta.TotalSize-offset) + index := start/geometry.ChunkSize + uint64(i) + offset := index * geometry.ChunkSize + expected := min(geometry.ChunkSize, geometry.TotalSize-offset) if uint64(len(data)) != expected { return nil, fmt.Errorf("payload chunk length differs from authenticated measurements") } diff --git a/unixfs/authentication_test.go b/unixfs/authentication_test.go index 0e50d79..11500d2 100644 --- a/unixfs/authentication_test.go +++ b/unixfs/authentication_test.go @@ -13,6 +13,7 @@ import ( filesystemservice "github.com/dewebprotocol/malt-client/filesystem/service" "github.com/dewebprotocol/malt-client/journal" "github.com/dewebprotocol/malt-client/unixfs" + unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" unixfsplanner "github.com/dewebprotocol/malt-client/unixfs/planner" "github.com/dewebprotocol/malt-core/auth/commitment/ipa" "github.com/dewebprotocol/malt-core/maltcid" @@ -121,7 +122,11 @@ func TestRootedRangeRejectsAuthenticatedIncorrectChunkLengths(t *testing.T) { if err != nil { t.Fatal(err) } - root, err := adapter.CreateMeasuredPayload(t.Context(), []cid.Cid{short, short}, 16, 8) + metadata, err := unixfsmodel.InlineChunkMetadata(16, 8, 2) + if err != nil { + t.Fatal(err) + } + root, err := adapter.CreatePositionalPayload(t.Context(), []cid.Cid{short, short}, metadata) if err != nil { t.Fatal(err) } diff --git a/unixfs/encrypted/builder.go b/unixfs/encrypted/builder.go index 34430b0..c455a99 100644 --- a/unixfs/encrypted/builder.go +++ b/unixfs/encrypted/builder.go @@ -20,12 +20,13 @@ import ( "unicode/utf8" "github.com/dewebprotocol/malt-client/unixfs" + unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" cid "github.com/ipfs/go-cid" ) type GraphWriter interface { CreateStagedRoot(context.Context, map[string]string) (cid.Cid, error) - unixfs.MeasuredPayloadWriter + unixfs.PositionalPayloadWriter } type BlockWriter interface { @@ -483,7 +484,19 @@ func (b *builder) buildFile(ctx context.Context, parent directoryBuildContext, n cipherChunkSize := uint64(b.chunkSize + envelopeOverhead) if len(chunks) > 1 { storage = StorageList - contentRoot, err = b.graph.CreateMeasuredPayload(ctx, chunks, totalCipher, cipherChunkSize) + metadata, encodeErr := unixfsmodel.EncodeChunkMetadata(totalCipher, cipherChunkSize, uint64(len(chunks))) + if encodeErr != nil { + return cid.Undef, encodeErr + } + payload, putErr := b.blocks.Put(ctx, metadata) + if putErr != nil { + return cid.Undef, putErr + } + if bindErr := unixfsmodel.VerifyPayloadCID(payload, metadata); bindErr != nil { + return cid.Undef, bindErr + } + stats.immutableBlocks++ + contentRoot, err = b.graph.CreatePositionalPayload(ctx, chunks, payload) if err != nil { return cid.Undef, fmt.Errorf("materialize encrypted UnixFS file List: %w", err) } diff --git a/unixfs/encrypted/snapshot.go b/unixfs/encrypted/snapshot.go index 5627bf8..36aeb00 100644 --- a/unixfs/encrypted/snapshot.go +++ b/unixfs/encrypted/snapshot.go @@ -217,8 +217,8 @@ func (g *recordingGraph) CreateStagedRoot(ctx context.Context, bindings map[stri } return g.record(ctx, state) } -func (g *recordingGraph) CreateMeasuredPayload(ctx context.Context, chunks []cid.Cid, total, chunk uint64) (cid.Cid, error) { - state := engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: g.profile}, ChunkSize: chunk, TotalSize: total} +func (g *recordingGraph) CreatePositionalPayload(ctx context.Context, chunks []cid.Cid, payload cid.Cid) (cid.Cid, error) { + state := engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: g.profile}, PayloadCID: payload} for i, target := range chunks { state.Entries = append(state.Entries, engine.Entry{Label: coordinate.EncodeIndex(uint64(i)), Target: target}) } diff --git a/unixfs/measured_payload.go b/unixfs/measured_payload.go index c2bb79c..b25b6c6 100644 --- a/unixfs/measured_payload.go +++ b/unixfs/measured_payload.go @@ -9,10 +9,10 @@ import ( cid "github.com/ipfs/go-cid" ) -// MeasuredPayloadWriter authenticates a chunk sequence and its exact byte -// measurements. A returned Root is an unaccepted candidate. -type MeasuredPayloadWriter interface { - CreateMeasuredPayload(context.Context, []cid.Cid, uint64, uint64) (cid.Cid, error) +// PositionalPayloadWriter binds a sequence and an opaque application metadata CID. +// A returned Root is an unaccepted candidate. +type PositionalPayloadWriter interface { + CreatePositionalPayload(context.Context, []cid.Cid, cid.Cid) (cid.Cid, error) } type declaredSizeReader struct { @@ -60,7 +60,7 @@ func (r *declaredSizeReader) validateComplete() error { // MaterializeMeasuredPayload streams payload chunks into CAS, commits the // measured Positional Root through typed authentication, and returns the new list root. -func MaterializeMeasuredPayload(ctx context.Context, blocks StagedBlockStore, writer MeasuredPayloadWriter, r io.Reader, totalSize uint64, chunkSize int) (cid.Cid, error) { +func MaterializeMeasuredPayload(ctx context.Context, blocks StagedBlockStore, writer PositionalPayloadWriter, r io.Reader, totalSize uint64, chunkSize int) (cid.Cid, error) { if blocks == nil { return cid.Undef, fmt.Errorf("block store is nil") } @@ -90,19 +90,30 @@ func MaterializeMeasuredPayload(ctx context.Context, blocks StagedBlockStore, wr if len(chunks) == 0 { return cid.Undef, fmt.Errorf("empty chunk sequence") } + metadata, err := unixfsmodel.EncodeChunkMetadata(totalSize, uint64(chunkSize), uint64(len(chunks))) + if err != nil { + return cid.Undef, err + } + payload, err := blocks.Put(ctx, metadata) + if err != nil { + return cid.Undef, fmt.Errorf("upload chunk metadata: %w", err) + } + if err = unixfsmodel.VerifyPayloadCID(payload, metadata); err != nil { + return cid.Undef, err + } if flusher, ok := blocks.(stagedBlockFlusher); ok { if err := flusher.Flush(ctx); err != nil { return cid.Undef, fmt.Errorf("flush payload chunks: %w", err) } } - return writer.CreateMeasuredPayload(ctx, chunks, totalSize, uint64(chunkSize)) + return writer.CreatePositionalPayload(ctx, chunks, payload) } // MaterializeStagedFilePayload stores a file payload according to the UnixFS // staged-add policy. Small files become raw CAS payloads; larger files become // measured Positional payloads. -func MaterializeStagedFilePayload(ctx context.Context, blocks StagedBlockStore, writer MeasuredPayloadWriter, r io.Reader, size int64, chunkSize int) (cid.Cid, bool, error) { +func MaterializeStagedFilePayload(ctx context.Context, blocks StagedBlockStore, writer PositionalPayloadWriter, r io.Reader, size int64, chunkSize int) (cid.Cid, bool, error) { if size < 0 { return cid.Undef, false, fmt.Errorf("file size must not be negative") } diff --git a/unixfs/model/chunk_metadata.go b/unixfs/model/chunk_metadata.go new file mode 100644 index 0000000..5dc043c --- /dev/null +++ b/unixfs/model/chunk_metadata.go @@ -0,0 +1,95 @@ +package unixfs + +import ( + "bytes" + "encoding/json" + "fmt" + cid "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" +) + +const ChunkMetadataProfile = "malt.unixfs.chunks/1" + +// ChunkMetadata is an application document, never a Core structural field. +// Count is supplied by the authenticated Positional root and is not duplicated. +type ChunkMetadata struct { + Profile string `json:"profile"` + ChunkSize uint64 `json:"chunk_size,string"` + TotalSize uint64 `json:"total_size,string"` +} + +func (m ChunkMetadata) Validate(count uint64) error { + if m.Profile != ChunkMetadataProfile || m.ChunkSize == 0 { + return fmt.Errorf("invalid chunk metadata profile or width") + } + expected := uint64(0) + if m.TotalSize > 0 { + expected = (m.TotalSize-1)/m.ChunkSize + 1 + } + if expected != count { + return fmt.Errorf("chunk metadata does not match authenticated count") + } + return nil +} + +func EncodeChunkMetadata(total, chunk, count uint64) ([]byte, error) { + m := ChunkMetadata{Profile: ChunkMetadataProfile, ChunkSize: chunk, TotalSize: total} + if err := m.Validate(count); err != nil { + return nil, err + } + return json.Marshal(m) +} + +func VerifyPayloadCID(key cid.Cid, data []byte) error { + if !key.Defined() { + return fmt.Errorf("missing metadata payload CID") + } + actual, err := key.Prefix().Sum(data) + if err != nil || !actual.Equals(key) { + return fmt.Errorf("metadata bytes do not match payload CID") + } + return nil +} + +func ParseChunkMetadata(key cid.Cid, data []byte, count uint64) (ChunkMetadata, error) { + var m ChunkMetadata + if err := VerifyPayloadCID(key, data); err != nil { + return m, err + } + if key.Type() != cid.Raw { + return m, fmt.Errorf("chunk metadata requires raw JSON content") + } + if err := json.Unmarshal(data, &m); err != nil { + return m, err + } + if err := m.Validate(count); err != nil { + return m, err + } + canonical, err := json.Marshal(m) + if err != nil || !bytes.Equal(canonical, data) { + return m, fmt.Errorf("noncanonical chunk metadata JSON") + } + return m, nil +} + +// InlineChunkMetadata binds the small document directly in an identity CID. +// Evaluators can retain this application data without an extra content store. +func InlineChunkMetadata(total, chunk, count uint64) (cid.Cid, error) { + data, err := EncodeChunkMetadata(total, chunk, count) + if err != nil { + return cid.Undef, err + } + hash, err := mh.Encode(data, mh.IDENTITY) + if err != nil { + return cid.Undef, err + } + return cid.NewCidV1(cid.Raw, hash), nil +} + +func ParseInlineChunkMetadata(key cid.Cid, count uint64) (ChunkMetadata, error) { + decoded, err := mh.Decode(key.Hash()) + if err != nil || decoded.Code != mh.IDENTITY { + return ChunkMetadata{}, fmt.Errorf("inline chunk metadata required") + } + return ParseChunkMetadata(key, decoded.Digest, count) +} diff --git a/unixfs/model/chunk_metadata_test.go b/unixfs/model/chunk_metadata_test.go new file mode 100644 index 0000000..9af706a --- /dev/null +++ b/unixfs/model/chunk_metadata_test.go @@ -0,0 +1,40 @@ +package unixfs + +import ( + cid "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" + "math" + "testing" +) + +func TestChunkMetadataBindsCountAndRejectsAmbiguousDocuments(t *testing.T) { + for _, tc := range []struct{ total, width, count uint64 }{{0, 8, 0}, {17, 8, 3}, {math.MaxUint64, 1, math.MaxUint64}, {math.MaxUint64, math.MaxUint64, 1}} { + key, err := InlineChunkMetadata(tc.total, tc.width, tc.count) + if err != nil { + t.Fatal(err) + } + got, err := ParseInlineChunkMetadata(key, tc.count) + if err != nil || got.TotalSize != tc.total || got.ChunkSize != tc.width { + t.Fatal("roundtrip", err) + } + if _, err := ParseInlineChunkMetadata(key, tc.count^1); err == nil { + t.Fatal("count mismatch accepted") + } + } + for _, data := range []string{ + `{"profile":"malt.unixfs.chunks/1","chunk_size":"0","total_size":"8"}`, + `{"profile":"malt.unixfs.chunks/1","chunk_size":"4","total_size":"8","total_size":"8"}`, + `{"profile":"malt.unixfs.chunks/1","chunk_size":"04","total_size":"8"}`, + `{"profile":"other","chunk_size":"4","total_size":"8"}`, + `{"profile":"malt.unixfs.chunks/1","chunk_size":"4","total_size":"8","height":0}`, + } { + key, _ := (cid.Prefix{Version: 1, Codec: cid.Raw, MhType: mh.SHA2_256, MhLength: -1}).Sum([]byte(data)) + if _, err := ParseChunkMetadata(key, []byte(data), 2); err == nil { + t.Fatal("accepted malformed document", data) + } + } + key, _ := InlineChunkMetadata(8, 4, 2) + if _, err := ParseChunkMetadata(key, []byte("{}"), 2); err == nil { + t.Fatal("unbound bytes accepted") + } +} diff --git a/unixfs/planner/writers.go b/unixfs/planner/writers.go index 2f82f27..0f8ca43 100644 --- a/unixfs/planner/writers.go +++ b/unixfs/planner/writers.go @@ -47,7 +47,7 @@ func (p *Planner) loadWriter(ctx context.Context, root cid.Cid) (*authentication return nil, err } // Conservatively charge vectors plus retained binding/tree bookkeeping. - size := uint64(1024 + len(root.Bytes())) + size := uint64(1024 + len(root.Bytes()) + len(candidate.State.PayloadCID.Bytes())) for _, entry := range candidate.State.Entries { size += uint64(512 + len(entry.Label) + len(entry.Target.Bytes())) } diff --git a/unixfs/verified_facade.go b/unixfs/verified_facade.go index 82460bc..3d353bf 100644 --- a/unixfs/verified_facade.go +++ b/unixfs/verified_facade.go @@ -143,7 +143,7 @@ type WriterOptions struct { Blocks BlockStore Verifier *engine.Engine Roots StagedRootWriter - Payloads MeasuredPayloadWriter + Payloads PositionalPayloadWriter Layout Layout ChunkSize int TempDir string @@ -173,7 +173,7 @@ type verifiedWriter struct { *verifiedReader store BlockStore roots StagedRootWriter - lists MeasuredPayloadWriter + lists PositionalPayloadWriter layout Layout chunkSize int tempDir string @@ -271,7 +271,7 @@ func NewWriter(opts WriterOptions) (Writer, error) { } lists := opts.Payloads if lists == nil { - lists, _ = opts.Roots.(MeasuredPayloadWriter) + lists, _ = opts.Roots.(PositionalPayloadWriter) } if lists == nil { return nil, fmt.Errorf("unixfs fixed-list payload writer is nil") @@ -438,8 +438,12 @@ func (r *verifiedReader) Stat(ctx context.Context, trustedRoot cid.Cid, rawPath if err != nil { return nil, err } - stat.Size = meta.TotalSize - stat.ChunkSize = meta.ChunkSize + geometry, err := r.readChunkMetadata(ctx, meta) + if err != nil { + return nil, err + } + stat.Size = geometry.TotalSize + stat.ChunkSize = geometry.ChunkSize stat.AuthenticationMetadata = proof case "raw": body, err := r.getBoundBlock(ctx, stat.Payload) diff --git a/unixfs/verified_facade_test.go b/unixfs/verified_facade_test.go index 83c7a34..5b322f1 100644 --- a/unixfs/verified_facade_test.go +++ b/unixfs/verified_facade_test.go @@ -1019,22 +1019,22 @@ func keptBody(result *unixfs.ReadResult) []byte { var _ unixfs.Remote = (*realRemote)(nil) var _ unixfs.BlockStore = (*realRemote)(nil) var _ unixfs.StagedRootWriter = (*realRemote)(nil) -var _ unixfs.MeasuredPayloadWriter = (*realRemote)(nil) +var _ unixfs.PositionalPayloadWriter = (*realRemote)(nil) var _ unixfs.Remote = (*countingWriterRemote)(nil) var _ unixfs.BlockStore = (*countingWriterRemote)(nil) var _ unixfs.StagedRootWriter = (*countingWriterRemote)(nil) -var _ unixfs.MeasuredPayloadWriter = (*countingWriterRemote)(nil) +var _ unixfs.PositionalPayloadWriter = (*countingWriterRemote)(nil) -func (r *realRemote) CreateMeasuredPayload(ctx context.Context, chunks []cid.Cid, total, chunk uint64) (cid.Cid, error) { +func (r *realRemote) CreatePositionalPayload(ctx context.Context, chunks []cid.Cid, payload cid.Cid) (cid.Cid, error) { adapter, err := unixfs.NewAuthenticationAdapter(unixfs.LayoutHybridV1, r, r.engine, maltcid.KZG4096) if err != nil { return cid.Undef, err } - return adapter.CreateMeasuredPayload(ctx, chunks, total, chunk) + return adapter.CreatePositionalPayload(ctx, chunks, payload) } -func (r *countingWriterRemote) CreateMeasuredPayload(ctx context.Context, chunks []cid.Cid, total, chunk uint64) (cid.Cid, error) { +func (r *countingWriterRemote) CreatePositionalPayload(ctx context.Context, chunks []cid.Cid, payload cid.Cid) (cid.Cid, error) { r.mutationCalls++ - return r.inner.CreateMeasuredPayload(ctx, chunks, total, chunk) + return r.inner.CreatePositionalPayload(ctx, chunks, payload) } func (r *countingWriterRemote) Authenticate(ctx context.Context, request protocol.AuthenticationRequest) (*protocol.AuthenticationResult, error) { r.remoteCalls++ diff --git a/writeplan/plan.go b/writeplan/plan.go index ec1f528..5f8cdd2 100644 --- a/writeplan/plan.go +++ b/writeplan/plan.go @@ -68,8 +68,8 @@ func (p Plan) validate() error { if err := check(candidate.Previous); err != nil { return err } - for _, entry := range candidate.State.Entries { - if err := check(entry.Target.String()); err != nil { + for _, target := range candidateTargets(candidate) { + if err := check(target.String()); err != nil { return err } } @@ -147,3 +147,14 @@ func (p Plan) PersistBatch(ctx context.Context, blocks BlockWriter, remote Batch } return receipt, nil } + +func candidateTargets(candidate protocol.AuthenticationCandidate) []cid.Cid { + targets := make([]cid.Cid, 0, len(candidate.State.Entries)+1) + if candidate.State.PayloadCID.Defined() { + targets = append(targets, candidate.State.PayloadCID) + } + for _, entry := range candidate.State.Entries { + targets = append(targets, entry.Target) + } + return targets +} diff --git a/writeplan/plan_test.go b/writeplan/plan_test.go index bd4d736..3b08a33 100644 --- a/writeplan/plan_test.go +++ b/writeplan/plan_test.go @@ -3,6 +3,7 @@ package writeplan import ( "context" "errors" + "strings" "testing" "github.com/dewebprotocol/malt-core/auth/commitment/ipa" @@ -100,3 +101,30 @@ func TestBatchReceiptBindsOriginalRequestEvenIfAdapterMutatesInput(t *testing.T) t.Fatal("receipt acknowledged a different prepared request") } } + +func TestPlanOrdersPositionalMetadataDependencies(t *testing.T) { + child := fixture(t).Candidates[0] + scheme, err := ipa.NewCommitterScheme(ipa.ProfileCompact) + if err != nil { + t.Fatal(err) + } + registry := engine.NewRegistry() + if err := registry.Register(scheme); err != nil { + t.Fatal(err) + } + parent, err := authentication.Prepare(t.Context(), engine.New(registry), engine.State{ + Descriptor: maltcid.RootDescriptor{Layout: maltcid.Positional, DerivationProfile: uint8(derivation.Direct), Profile: maltcid.IPA256}, + PayloadCID: cid.MustParse(child.Root), + }) + if err != nil { + t.Fatal(err) + } + plan := Plan{Root: cid.MustParse(parent.Root), Candidates: []protocol.AuthenticationCandidate{child, parent}} + if err := plan.validate(); err != nil { + t.Fatal(err) + } + plan.Candidates = []protocol.AuthenticationCandidate{parent, child} + if err := plan.validate(); err == nil || !strings.Contains(err.Error(), "child-before-parent") { + t.Fatalf("metadata dependency was not ordered: %v", err) + } +} diff --git a/writeplan/staging.go b/writeplan/staging.go index 192df4d..52746fd 100644 --- a/writeplan/staging.go +++ b/writeplan/staging.go @@ -136,8 +136,8 @@ func (s *Staging) Plan(base, root cid.Cid) (Plan, error) { needed[key] = true candidate := s.candidates[index] visit(candidate.Previous) - for _, entry := range candidate.State.Entries { - visit(entry.Target.String()) + for _, target := range candidateTargets(candidate) { + visit(target.String()) } } visit(root.String())