@cryptotaxi247 / kubo / commits / 33848850d

fix(rpc): validate UnixFS in `object patch` (#11248)

* fix(object): validate UnixFS type in patch add-link Reject adding named links to non-directory nodes in `object patch add-link`, which previously produced invalid DAGs silently. - reject UnixFS File/Symlink/etc nodes (only Directory and HAMTShard support named links per the UnixFS spec) - reject non-UnixFS dag-pb nodes (no UnixFS metadata to validate) - add `--allow-non-unixfs` flag to bypass both checks - pass `allow-non-unixfs` in client/rpc when SkipUnixFSValidation is set - test all three node types: bare dag-pb, UnixFS File, UnixFS Directory - reproduce the exact data-loss scenario from #7190 Fixes: https://github.com/ipfs/kubo/issues/7190 * fix(object): reject HAMTShard in patch add-link dagutils.Editor operates at the dag-pb level and does not update HAMT bitfields, so mutating HAMTShard nodes produces corrupt DAGs. - reject HAMTShard in add-link (was incorrectly allowed) - update help text to note dag-pb limitations and suggest ipfs files - add HAMT test cases to sharness and API tests - expect full error strings in all validation tests - update changelog to cover all rejected node types * fix(object): validate UnixFS type in patch rm-link Same issue as add-link: dagutils.Editor operates at the dag-pb level and cannot update UnixFS metadata, so mutating non-Directory nodes produces corrupt DAGs. - add UnixFS validation to rm-link (Directory allowed, all else rejected) - add --allow-non-unixfs flag to rm-link command - add ObjectRmLinkSettings/ObjectRmLinkOption types - update ObjectAPI.RmLink interface to accept options - pass allow-non-unixfs in client/rpc - update rm-link help text to note dag-pb limitations - add rm-link validation tests for all four node types

Marcin Rataj committed Mar 27, 2026 at 20:22 UTC 33848850d5d38fb05d4af8032bbd3c171c42463d
8 files changed +430 -21
client/rpc/object.go
+9 -2
@@ -24,6 +24,7 @@ func (api *ObjectAPI) AddLink(ctx context.Context, base path.Path, name string,
24 var out objectOut
25 err = api.core().Request("object/patch/add-link", base.String(), name, child.String()).
26 Option("create", options.Create).
27 + Option("allow-non-unixfs", options.SkipUnixFSValidation).
28 Exec(ctx, &out)
29 if err != nil {
30 return path.ImmutablePath{}, err
@@ -37,9 +38,15 @@ func (api *ObjectAPI) AddLink(ctx context.Context, base path.Path, name string,
38 return path.FromCid(c), nil
39 }
40
40 -func (api *ObjectAPI) RmLink(ctx context.Context, base path.Path, link string) (path.ImmutablePath, error) {
41 +func (api *ObjectAPI) RmLink(ctx context.Context, base path.Path, link string, opts ...caopts.ObjectRmLinkOption) (path.ImmutablePath, error) {
42 + options, err := caopts.ObjectRmLinkOptions(opts...)
43 + if err != nil {
44 + return path.ImmutablePath{}, err
45 + }
46 +
47 var out objectOut
42 - err := api.core().Request("object/patch/rm-link", base.String(), link).
48 + err = api.core().Request("object/patch/rm-link", base.String(), link).
49 + Option("allow-non-unixfs", options.SkipUnixFSValidation).
50 Exec(ctx, &out)
51 if err != nil {
52 return path.ImmutablePath{}, err
core/commands/object/patch.go
+27 -6
@@ -54,13 +54,23 @@ var patchRmLinkCmd = &cmds.Command{
54 ShortDescription: `
55 Remove a Merkle-link from the given object and return the hash of the result.
56
57 -DEPRECATED and provided for legacy reasons. Use 'files rm' instead.
57 +DEPRECATED and provided for legacy reasons.
58 +
59 +This command operates at the dag-pb level and only supports removing links
60 +from small, flat UnixFS directories (not HAMTShard). Removing links from
61 +files or large sharded directories will produce invalid UnixFS structures.
62 +
63 +For working with any UnixFS directories (including large/sharded ones),
64 +use 'ipfs files rm' instead: 'ipfs files --help'.
65 `,
66 },
67 Arguments: []cmds.Argument{
68 cmds.StringArg("root", true, false, "The hash of the node to modify."),
69 cmds.StringArg("name", true, false, "Name of the link to remove."),
70 },
71 + Options: []cmds.Option{
72 + cmds.BoolOption(allowNonUnixFSOptionName, "", "Skip UnixFS validation, allowing link removal on non-directory nodes."),
73 + },
74 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
75 api, err := cmdenv.GetApi(env, req)
76 if err != nil {
@@ -73,7 +83,9 @@ DEPRECATED and provided for legacy reasons. Use 'files rm' instead.
83 }
84
85 name := req.Arguments[1]
76 - p, err := api.Object().RmLink(req.Context, root, name)
86 + allowNonUnixFS, _ := req.Options[allowNonUnixFSOptionName].(bool)
87 + p, err := api.Object().RmLink(req.Context, root, name,
88 + options.Object.RmLinkSkipUnixFSValidation(allowNonUnixFS))
89 if err != nil {
90 return err
91 }
@@ -94,7 +106,8 @@ DEPRECATED and provided for legacy reasons. Use 'files rm' instead.
106 }
107
108 const (
97 - createOptionName = "create"
109 + createOptionName = "create"
110 + allowNonUnixFSOptionName = "allow-non-unixfs"
111 )
112
113 var patchAddLinkCmd = &cmds.Command{
@@ -106,14 +119,19 @@ Add a Merkle-link to the given object and return the hash of the result.
119
120 DEPRECATED and provided for legacy reasons.
121
109 -Use MFS and 'files' commands instead:
122 +This command operates at the dag-pb level and only supports adding links
123 +to small, flat UnixFS directories (not HAMTShard). Adding links to files
124 +or large sharded directories will produce invalid UnixFS structures.
125 +
126 +For working with any UnixFS directories (including large/sharded ones),
127 +use MFS and 'files' commands instead: 'ipfs files --help'.
128
129 $ ipfs files cp /ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn /some-dir
130 $ ipfs files cp /ipfs/Qmayz4F4UzqcAMitTzU4zCSckDofvxstDuj3y7ajsLLEVs /some-dir/added-file.jpg
131 $ ipfs files stat --hash /some-dir
132
133 The above will add 'added-file.jpg' to the directory placed under /some-dir
116 - and the CID of updated directory is returned by 'files stat'
134 + and the CID of updated directory is returned by 'files stat'.
135
136 'files cp' does not download the data, only the root block, which makes it
137 possible to build arbitrary directory trees without fetching them in full to
@@ -127,6 +145,7 @@ Use MFS and 'files' commands instead:
145 },
146 Options: []cmds.Option{
147 cmds.BoolOption(createOptionName, "p", "Create intermediary nodes."),
148 + cmds.BoolOption(allowNonUnixFSOptionName, "", "Skip UnixFS validation, allowing links on non-directory nodes."),
149 },
150 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
151 api, err := cmdenv.GetApi(env, req)
@@ -150,9 +169,11 @@ Use MFS and 'files' commands instead:
169 if err != nil {
170 return err
171 }
172 + allowNonUnixFS, _ := req.Options[allowNonUnixFSOptionName].(bool)
173
174 p, err := api.Object().AddLink(req.Context, root, name, child,
155 - options.Object.Create(create))
175 + options.Object.Create(create),
176 + options.Object.SkipUnixFSValidation(allowNonUnixFS))
177 if err != nil {
178 return err
179 }
core/coreapi/object.go
+64 -1
@@ -2,6 +2,7 @@ package coreapi
2
3 import (
4 "context"
5 + "fmt"
6
7 dag "github.com/ipfs/boxo/ipld/merkledag"
8 "github.com/ipfs/boxo/ipld/merkledag/dagutils"
@@ -56,6 +57,37 @@ func (api *ObjectAPI) AddLink(ctx context.Context, base path.Path, name string,
57 return path.ImmutablePath{}, dag.ErrNotProtobuf
58 }
59
60 + // This command operates at the dag-pb level via dagutils.Editor, which
61 + // only manipulates ProtoNode links without updating UnixFS metadata.
62 + // Only plain UnixFS Directory nodes are safe to mutate this way.
63 + // File nodes: adding links corrupts Blocksizes, content lost on read-back.
64 + // HAMTShard nodes: bitfield not updated, shard trie becomes inconsistent.
65 + // https://specs.ipfs.tech/unixfs/#pbnode-links-name
66 + // https://github.com/ipfs/kubo/issues/7190
67 + if !options.SkipUnixFSValidation {
68 + fsNode, err := ft.FSNodeFromBytes(basePb.Data())
69 + if err != nil {
70 + return path.ImmutablePath{}, fmt.Errorf(
71 + "cannot add named links to a non-UnixFS dag-pb node; " +
72 + "pass --allow-non-unixfs to skip validation")
73 + }
74 + switch fsNode.Type() {
75 + case ft.TDirectory:
76 + // plain directories: safe, no link-count metadata to desync
77 + case ft.THAMTShard:
78 + return path.ImmutablePath{}, fmt.Errorf(
79 + "cannot add links to a HAMTShard at the dag-pb level " +
80 + "(would corrupt the HAMT bitfield); use 'ipfs files' " +
81 + "commands instead, or pass --allow-non-unixfs to override")
82 + default:
83 + return path.ImmutablePath{}, fmt.Errorf(
84 + "cannot add named links to a UnixFS %s node, "+
85 + "only Directory nodes support link addition at the dag-pb level "+
86 + "(see https://specs.ipfs.tech/unixfs/)",
87 + fsNode.Type())
88 + }
89 + }
90 +
91 var createfunc func() *dag.ProtoNode
92 if options.Create {
93 createfunc = ft.EmptyDirNode
@@ -76,13 +108,18 @@ func (api *ObjectAPI) AddLink(ctx context.Context, base path.Path, name string,
108 return path.FromCid(nnode.Cid()), nil
109 }
110
79 -func (api *ObjectAPI) RmLink(ctx context.Context, base path.Path, link string) (path.ImmutablePath, error) {
111 +func (api *ObjectAPI) RmLink(ctx context.Context, base path.Path, link string, opts ...caopts.ObjectRmLinkOption) (path.ImmutablePath, error) {
112 ctx, span := tracing.Span(ctx, "CoreAPI.ObjectAPI", "RmLink", trace.WithAttributes(
113 attribute.String("base", base.String()),
114 attribute.String("link", link)),
115 )
116 defer span.End()
117
118 + options, err := caopts.ObjectRmLinkOptions(opts...)
119 + if err != nil {
120 + return path.ImmutablePath{}, err
121 + }
122 +
123 baseNd, err := api.core().ResolveNode(ctx, base)
124 if err != nil {
125 return path.ImmutablePath{}, err
@@ -93,6 +130,32 @@ func (api *ObjectAPI) RmLink(ctx context.Context, base path.Path, link string) (
130 return path.ImmutablePath{}, dag.ErrNotProtobuf
131 }
132
133 + // Same validation as AddLink: dagutils.Editor operates at the dag-pb
134 + // level and cannot update UnixFS metadata (HAMT bitfields, Blocksizes).
135 + if !options.SkipUnixFSValidation {
136 + fsNode, err := ft.FSNodeFromBytes(basePb.Data())
137 + if err != nil {
138 + return path.ImmutablePath{}, fmt.Errorf(
139 + "cannot remove links from a non-UnixFS dag-pb node; " +
140 + "pass --allow-non-unixfs to skip validation")
141 + }
142 + switch fsNode.Type() {
143 + case ft.TDirectory:
144 + // plain directories: safe, no link-count metadata to desync
145 + case ft.THAMTShard:
146 + return path.ImmutablePath{}, fmt.Errorf(
147 + "cannot remove links from a HAMTShard at the dag-pb level " +
148 + "(would corrupt the HAMT bitfield); use 'ipfs files rm' " +
149 + "instead, or pass --allow-non-unixfs to override")
150 + default:
151 + return path.ImmutablePath{}, fmt.Errorf(
152 + "cannot remove links from a UnixFS %s node, "+
153 + "only Directory nodes support link removal at the dag-pb level "+
154 + "(see https://specs.ipfs.tech/unixfs/)",
155 + fsNode.Type())
156 + }
157 + }
158 +
159 e := dagutils.NewDagEditor(basePb, api.dag)
160
161 err = e.RmLink(ctx, link)
core/coreiface/object.go
+1 -1
@@ -50,7 +50,7 @@ type ObjectAPI interface {
50 AddLink(ctx context.Context, base path.Path, name string, child path.Path, opts ...options.ObjectAddLinkOption) (path.ImmutablePath, error)
51
52 // RmLink removes a link from the node
53 - RmLink(ctx context.Context, base path.Path, link string) (path.ImmutablePath, error)
53 + RmLink(ctx context.Context, base path.Path, link string, opts ...options.ObjectRmLinkOption) (path.ImmutablePath, error)
54
55 // Diff returns a set of changes needed to transform the first object into the
56 // second.
core/coreiface/options/object.go
+42 -1
@@ -1,7 +1,8 @@
1 package options
2
3 type ObjectAddLinkSettings struct {
4 - Create bool
4 + Create bool
5 + SkipUnixFSValidation bool
6 }
7
8 type (
@@ -34,3 +35,43 @@ func (objectOpts) Create(create bool) ObjectAddLinkOption {
35 return nil
36 }
37 }
38 +
39 +// SkipUnixFSValidation is an option for Object.AddLink which skips the check
40 +// that only allows adding named links to UnixFS directory nodes.
41 +// Use this when operating on raw dag-pb nodes outside of UnixFS semantics.
42 +func (objectOpts) SkipUnixFSValidation(skip bool) ObjectAddLinkOption {
43 + return func(settings *ObjectAddLinkSettings) error {
44 + settings.SkipUnixFSValidation = skip
45 + return nil
46 + }
47 +}
48 +
49 +type ObjectRmLinkSettings struct {
50 + SkipUnixFSValidation bool
51 +}
52 +
53 +type (
54 + ObjectRmLinkOption func(*ObjectRmLinkSettings) error
55 +)
56 +
57 +func ObjectRmLinkOptions(opts ...ObjectRmLinkOption) (*ObjectRmLinkSettings, error) {
58 + options := &ObjectRmLinkSettings{}
59 +
60 + for _, opt := range opts {
61 + err := opt(options)
62 + if err != nil {
63 + return nil, err
64 + }
65 + }
66 + return options, nil
67 +}
68 +
69 +// RmLinkSkipUnixFSValidation is an option for Object.RmLink which skips the
70 +// check that only allows removing links from UnixFS directory nodes.
71 +// Use this when operating on raw dag-pb nodes outside of UnixFS semantics.
72 +func (objectOpts) RmLinkSkipUnixFSValidation(skip bool) ObjectRmLinkOption {
73 + return func(settings *ObjectRmLinkSettings) error {
74 + settings.SkipUnixFSValidation = skip
75 + return nil
76 + }
77 +}
core/coreiface/tests/object.go
+132 -4
@@ -5,6 +5,7 @@ import (
5 "testing"
6
7 dag "github.com/ipfs/boxo/ipld/merkledag"
8 + ft "github.com/ipfs/boxo/ipld/unixfs"
9 "github.com/ipfs/boxo/path"
10 ipld "github.com/ipfs/go-ipld-format"
11 iface "github.com/ipfs/kubo/core/coreiface"
@@ -22,7 +23,9 @@ func (tp *TestSuite) TestObject(t *testing.T) {
23
24 t.Run("TestObjectAddLink", tp.TestObjectAddLink)
25 t.Run("TestObjectAddLinkCreate", tp.TestObjectAddLinkCreate)
26 + t.Run("TestObjectAddLinkValidation", tp.TestObjectAddLinkValidation)
27 t.Run("TestObjectRmLink", tp.TestObjectRmLink)
28 + t.Run("TestObjectRmLinkValidation", tp.TestObjectRmLinkValidation)
29 t.Run("TestDiffTest", tp.TestDiffTest)
30 }
31
@@ -58,7 +61,8 @@ func (tp *TestSuite) TestObjectAddLink(t *testing.T) {
61 },
62 })
63
61 - p3, err := api.Object().AddLink(ctx, p2, "abc", p2)
64 + // Raw dag-pb nodes require SkipUnixFSValidation since they have no UnixFS metadata
65 + p3, err := api.Object().AddLink(ctx, p2, "abc", p2, opt.Object.SkipUnixFSValidation(true))
66 require.NoError(t, err)
67
68 nd, err := api.Dag().Get(ctx, p3.RootCid())
@@ -84,10 +88,11 @@ func (tp *TestSuite) TestObjectAddLinkCreate(t *testing.T) {
88 },
89 })
90
87 - _, err = api.Object().AddLink(ctx, p2, "abc/d", p2)
91 + // Raw dag-pb nodes require SkipUnixFSValidation since they have no UnixFS metadata
92 + _, err = api.Object().AddLink(ctx, p2, "abc/d", p2, opt.Object.SkipUnixFSValidation(true))
93 require.ErrorContains(t, err, "no link by that name")
94
90 - p3, err := api.Object().AddLink(ctx, p2, "abc/d", p2, opt.Object.Create(true))
95 + p3, err := api.Object().AddLink(ctx, p2, "abc/d", p2, opt.Object.Create(true), opt.Object.SkipUnixFSValidation(true))
96 require.NoError(t, err)
97
98 nd, err := api.Dag().Get(ctx, p3.RootCid())
@@ -99,6 +104,65 @@ func (tp *TestSuite) TestObjectAddLinkCreate(t *testing.T) {
104 require.Equal(t, "bar", links[1].Name)
105 }
106
107 +// TestObjectAddLinkValidation verifies that AddLink rejects non-directory
108 +// nodes by default, preventing the data-loss bug in
109 +// https://github.com/ipfs/kubo/issues/7190
110 +func (tp *TestSuite) TestObjectAddLinkValidation(t *testing.T) {
111 + ctx := t.Context()
112 + api, err := tp.makeAPI(t, ctx)
113 + require.NoError(t, err)
114 +
115 + child := putDagPbNode(t, ctx, api, "child", nil)
116 +
117 + // UnixFS Directory: allowed
118 + dirNode := ft.EmptyDirNode()
119 + err = api.Dag().Add(ctx, dirNode)
120 + require.NoError(t, err)
121 + dirPath := path.FromCid(dirNode.Cid())
122 +
123 + _, err = api.Object().AddLink(ctx, dirPath, "foo", child)
124 + require.NoError(t, err)
125 +
126 + // UnixFS File: rejected (would cause data loss on read-back)
127 + fileNode := ft.EmptyFileNode()
128 + err = api.Dag().Add(ctx, fileNode)
129 + require.NoError(t, err)
130 + filePath := path.FromCid(fileNode.Cid())
131 +
132 + _, err = api.Object().AddLink(ctx, filePath, "foo", child)
133 + require.ErrorContains(t, err, "cannot add named links to a UnixFS File node, only Directory nodes support link addition at the dag-pb level")
134 +
135 + // UnixFS File with SkipUnixFSValidation: allowed (user takes responsibility)
136 + _, err = api.Object().AddLink(ctx, filePath, "foo", child, opt.Object.SkipUnixFSValidation(true))
137 + require.NoError(t, err)
138 +
139 + // HAMTShard: rejected (dag-pb level mutation corrupts HAMT bitfield)
140 + hamtData, err := ft.HAMTShardData(nil, 256, 0x22)
141 + require.NoError(t, err)
142 + hamtNode := new(dag.ProtoNode)
143 + hamtNode.SetData(hamtData)
144 + err = api.Dag().Add(ctx, hamtNode)
145 + require.NoError(t, err)
146 + hamtPath := path.FromCid(hamtNode.Cid())
147 +
148 + _, err = api.Object().AddLink(ctx, hamtPath, "foo", child)
149 + require.ErrorContains(t, err, "cannot add links to a HAMTShard at the dag-pb level (would corrupt the HAMT bitfield); use 'ipfs files' commands instead, or pass --allow-non-unixfs to override")
150 +
151 + // HAMTShard with SkipUnixFSValidation: allowed
152 + _, err = api.Object().AddLink(ctx, hamtPath, "foo", child, opt.Object.SkipUnixFSValidation(true))
153 + require.NoError(t, err)
154 +
155 + // Raw dag-pb (no UnixFS data): rejected
156 + rawPb := putDagPbNode(t, ctx, api, "", nil)
157 +
158 + _, err = api.Object().AddLink(ctx, rawPb, "foo", child)
159 + require.ErrorContains(t, err, "cannot add named links to a non-UnixFS dag-pb node; pass --allow-non-unixfs to skip validation")
160 +
161 + // Raw dag-pb with SkipUnixFSValidation: allowed
162 + _, err = api.Object().AddLink(ctx, rawPb, "foo", child, opt.Object.SkipUnixFSValidation(true))
163 + require.NoError(t, err)
164 +}
165 +
166 func (tp *TestSuite) TestObjectRmLink(t *testing.T) {
167 ctx := t.Context()
168 api, err := tp.makeAPI(t, ctx)
@@ -113,7 +177,8 @@ func (tp *TestSuite) TestObjectRmLink(t *testing.T) {
177 },
178 })
179
116 - p3, err := api.Object().RmLink(ctx, p2, "bar")
180 + // Raw dag-pb nodes require SkipUnixFSValidation since they have no UnixFS metadata
181 + p3, err := api.Object().RmLink(ctx, p2, "bar", opt.Object.RmLinkSkipUnixFSValidation(true))
182 require.NoError(t, err)
183
184 nd, err := api.Dag().Get(ctx, p3.RootCid())
@@ -123,6 +188,69 @@ func (tp *TestSuite) TestObjectRmLink(t *testing.T) {
188 require.Len(t, links, 0)
189 }
190
191 +// TestObjectRmLinkValidation verifies that RmLink rejects non-directory
192 +// nodes by default, preventing silent DAG corruption.
193 +func (tp *TestSuite) TestObjectRmLinkValidation(t *testing.T) {
194 + ctx := t.Context()
195 + api, err := tp.makeAPI(t, ctx)
196 + require.NoError(t, err)
197 +
198 + child := putDagPbNode(t, ctx, api, "child", nil)
199 +
200 + // UnixFS Directory with a link: rm-link allowed
201 + dirNode := ft.EmptyDirNode()
202 + childNd, err := api.Dag().Get(ctx, child.RootCid())
203 + require.NoError(t, err)
204 + err = dirNode.AddNodeLink("foo", childNd)
205 + require.NoError(t, err)
206 + err = api.Dag().Add(ctx, dirNode)
207 + require.NoError(t, err)
208 + dirPath := path.FromCid(dirNode.Cid())
209 +
210 + _, err = api.Object().RmLink(ctx, dirPath, "foo")
211 + require.NoError(t, err)
212 +
213 + // UnixFS File: rejected
214 + fileNode := ft.EmptyFileNode()
215 + err = api.Dag().Add(ctx, fileNode)
216 + require.NoError(t, err)
217 + filePath := path.FromCid(fileNode.Cid())
218 +
219 + _, err = api.Object().RmLink(ctx, filePath, "foo")
220 + require.ErrorContains(t, err, "cannot remove links from a UnixFS File node, only Directory nodes support link removal at the dag-pb level")
221 +
222 + // UnixFS File with SkipUnixFSValidation: allowed
223 + _, err = api.Object().RmLink(ctx, filePath, "foo", opt.Object.RmLinkSkipUnixFSValidation(true))
224 + // ErrLinkNotFound is expected since the file has no links, but validation passed
225 + require.ErrorContains(t, err, "no link by that name")
226 +
227 + // HAMTShard: rejected
228 + hamtData, err := ft.HAMTShardData(nil, 256, 0x22)
229 + require.NoError(t, err)
230 + hamtNode := new(dag.ProtoNode)
231 + hamtNode.SetData(hamtData)
232 + err = api.Dag().Add(ctx, hamtNode)
233 + require.NoError(t, err)
234 + hamtPath := path.FromCid(hamtNode.Cid())
235 +
236 + _, err = api.Object().RmLink(ctx, hamtPath, "foo")
237 + require.ErrorContains(t, err, "cannot remove links from a HAMTShard at the dag-pb level (would corrupt the HAMT bitfield); use 'ipfs files rm' instead, or pass --allow-non-unixfs to override")
238 +
239 + // HAMTShard with SkipUnixFSValidation: allowed (validation bypassed)
240 + _, err = api.Object().RmLink(ctx, hamtPath, "foo", opt.Object.RmLinkSkipUnixFSValidation(true))
241 + require.ErrorContains(t, err, "no link by that name")
242 +
243 + // Raw dag-pb (no UnixFS data): rejected
244 + rawPb := putDagPbNode(t, ctx, api, "", nil)
245 +
246 + _, err = api.Object().RmLink(ctx, rawPb, "foo")
247 + require.ErrorContains(t, err, "cannot remove links from a non-UnixFS dag-pb node; pass --allow-non-unixfs to skip validation")
248 +
249 + // Raw dag-pb with SkipUnixFSValidation: allowed
250 + _, err = api.Object().RmLink(ctx, rawPb, "foo", opt.Object.RmLinkSkipUnixFSValidation(true))
251 + require.ErrorContains(t, err, "no link by that name")
252 +}
253 +
254 func (tp *TestSuite) TestDiffTest(t *testing.T) {
255 ctx := t.Context()
256 api, err := tp.makeAPI(t, ctx)
docs/changelogs/v0.41.md
+22
@@ -14,6 +14,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
14 - [✨ New `ipfs cid inspect` command](#-new-ipfs-cid-inspect-command)
15 - [🖥️ WebUI Improvements](#-webui-improvements)
16 - [🔧 Correct provider addresses for custom HTTP routing](#-correct-provider-addresses-for-custom-http-routing)
17 + - [`ipfs object patch` validates UnixFS node types](#ipfs-object-patch-validates-unixfs-node-types)
18 - [📦️ Dependency updates](#-dependency-updates)
19 - [📝 Changelog](#-changelog)
20 - [👨‍👩‍👧‍👦 Contributors](#-contributors)
@@ -76,6 +77,27 @@ Peer locations load faster thanks to UX optimizations in the underlying ipfs-geo
77
78 Nodes using custom routing (`Routing.Type=custom`) with [IPIP-526](https://github.com/ipfs/specs/pull/526) could end up publishing unresolved `0.0.0.0` addresses in provider records. Addresses are now resolved at provide-time, and when AutoNAT V2 has confirmed publicly reachable addresses, those are preferred automatically. See [#11213](https://github.com/ipfs/kubo/issues/11213).
79
80 +#### `ipfs object patch` validates UnixFS node types
81 +
82 +As part of the ongoing deprecation of the legacy `ipfs object` API (which
83 +predates HAMTShard directories and CIDv1), the `add-link` and `rm-link`
84 +subcommands now validate the root node before mutating it.
85 +
86 +These commands operate at the raw `dag-pb` level and can only safely mutate
87 +small, flat UnixFS directories. They are unable to update UnixFS metadata
88 +(HAMT bitfields, file `Blocksizes`), so using them on files or sharded
89 +directories would silently produce invalid DAGs. This is now rejected:
90 +
91 +- **File** nodes: rejected (corrupts `Blocksizes`, content lost on read-back)
92 +- **HAMTShard** nodes: rejected (HAMT bitfield not updated, corrupts directory)
93 +- **Non-UnixFS `dag-pb`** nodes: rejected by default
94 +- **Directory** nodes: allowed (the only safe case)
95 +
96 +Use `ipfs files` commands (`mkdir`, `cp`, `rm`, `mv`) instead. They handle all
97 +directory types correctly, including large sharded directories.
98 +
99 +A `--allow-non-unixfs` flag is available on both `ipfs object patch` commands to bypass validation.
100 +
101 #### 📦️ Dependency updates
102
103 - update `go-libp2p` to [v0.48.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.48.0)
test/sharness/t0051-object.sh
+133 -6
@@ -27,15 +27,87 @@ test_patch_create_path() {
27 }
28
29 test_object_cmd() {
30 + # Bare dag-pb node with no UnixFS metadata (0 bytes of protobuf data)
31 EMPTY_DIR=$(echo '{"Links":[]}' | ipfs dag put --store-codec dag-pb)
32 + # Empty UnixFS directory (equivalent to QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn)
33 EMPTY_UNIXFS_DIR=$(echo '{"Data":{"/":{"bytes":"CAE"}},"Links":[]}' | ipfs dag put --store-codec dag-pb)
34 + # Empty UnixFS file (QmbFMke1KXqnYyBBWxB74N4c5SBnJMVAiMNRcGu6x1AwQH)
35 + EMPTY_UNIXFS_FILE=$(echo -n | ipfs add -q)
36 + # Empty HAMTShard (Type=HAMTShard, HashType=0x22, Fanout=256)
37 + EMPTY_HAMT=$(echo '{"Data":{"/":{"bytes":"CAUoIjCAAg"}},"Links":[]}' | ipfs dag put --store-codec dag-pb)
38 +
39 + # --- UnixFS validation for 'object patch add-link' ---
40 + # 'object patch' operates at the dag-pb level via dagutils.Editor, which
41 + # only manipulates ProtoNode links without updating UnixFS metadata.
42 + # Only plain UnixFS Directory nodes are safe to mutate this way.
43 + # https://specs.ipfs.tech/unixfs/#pbnode-links-name
44 + # https://github.com/ipfs/kubo/issues/7190
45 + #
46 + # Four root node types tested below:
47 + # 1) bare dag-pb (no UnixFS data) -- rejected
48 + # 2) UnixFS File -- rejected (prevents data loss)
49 + # 3) HAMTShard -- rejected (corrupts HAMT bitfield)
50 + # 4) UnixFS Directory -- allowed
51 +
52 + # Reproduce https://github.com/ipfs/kubo/issues/7190:
53 + # adding a named link to a File node must be rejected to prevent data loss.
54 + test_expect_success "'ipfs object patch add-link' prevents data loss on File nodes (#7190)" '
55 + echo "original content" > original.txt &&
56 + ORIGINAL_CID=$(ipfs add -q original.txt) &&
57 + CHILD_CID=$(echo "child" | ipfs add -q) &&
58 + test_expect_code 1 ipfs object patch $ORIGINAL_CID add-link "child.txt" $CHILD_CID 2>patch_7190_err &&
59 + echo "Error: cannot add named links to a UnixFS File node, only Directory nodes support link addition at the dag-pb level (see https://specs.ipfs.tech/unixfs/)" >patch_7190_expected &&
60 + test_cmp patch_7190_expected patch_7190_err &&
61 + # verify the original file is still intact
62 + ipfs cat $ORIGINAL_CID > original_readback.txt &&
63 + test_cmp original.txt original_readback.txt
64 + '
65 +
66 + # 1) Bare dag-pb (no UnixFS data): rejected by default
67 + test_expect_success "'ipfs object patch add-link' rejects non-UnixFS dag-pb nodes" '
68 + test_expect_code 1 ipfs object patch $EMPTY_DIR add-link foo $EMPTY_UNIXFS_DIR 2>patch_dagpb_err
69 + '
70 +
71 + test_expect_success "add-link error for non-UnixFS dag-pb has expected message" '
72 + echo "Error: cannot add named links to a non-UnixFS dag-pb node; pass --allow-non-unixfs to skip validation" >patch_dagpb_expected &&
73 + test_cmp patch_dagpb_expected patch_dagpb_err
74 + '
75
33 - test_expect_success "'ipfs object patch' should work (no unixfs-dir)" '
34 - OUTPUT=$(ipfs object patch $EMPTY_DIR add-link foo $EMPTY_DIR) &&
76 + test_expect_success "'ipfs object patch add-link --allow-non-unixfs' works on dag-pb nodes" '
77 + OUTPUT=$(ipfs object patch $EMPTY_DIR add-link --allow-non-unixfs foo $EMPTY_UNIXFS_DIR) &&
78 ipfs dag stat $OUTPUT
79 '
80
38 - test_expect_success "'ipfs object patch' should work" '
81 + # 2) UnixFS File (QmbFMke1KXqnYyBBWxB74N4c5SBnJMVAiMNRcGu6x1AwQH): rejected by default
82 + test_expect_success "'ipfs object patch add-link' rejects UnixFS File nodes" '
83 + test_expect_code 1 ipfs object patch $EMPTY_UNIXFS_FILE add-link foo $EMPTY_UNIXFS_DIR 2>patch_file_err
84 + '
85 +
86 + test_expect_success "add-link error for UnixFS File has expected message" '
87 + echo "Error: cannot add named links to a UnixFS File node, only Directory nodes support link addition at the dag-pb level (see https://specs.ipfs.tech/unixfs/)" >patch_file_expected &&
88 + test_cmp patch_file_expected patch_file_err
89 + '
90 +
91 + test_expect_success "'ipfs object patch add-link --allow-non-unixfs' bypasses check on File nodes" '
92 + ipfs object patch $EMPTY_UNIXFS_FILE add-link --allow-non-unixfs foo $EMPTY_UNIXFS_DIR
93 + '
94 +
95 + # 3) HAMTShard: rejected (dag-pb level mutation corrupts HAMT bitfield)
96 + test_expect_success "'ipfs object patch add-link' rejects HAMTShard nodes" '
97 + test_expect_code 1 ipfs object patch $EMPTY_HAMT add-link foo $EMPTY_UNIXFS_DIR 2>patch_hamt_err
98 + '
99 +
100 + test_expect_success "add-link error for HAMTShard has expected message" '
101 + echo "Error: cannot add links to a HAMTShard at the dag-pb level (would corrupt the HAMT bitfield); use '"'"'ipfs files'"'"' commands instead, or pass --allow-non-unixfs to override" >patch_hamt_expected &&
102 + test_cmp patch_hamt_expected patch_hamt_err
103 + '
104 +
105 + test_expect_success "'ipfs object patch add-link --allow-non-unixfs' bypasses check on HAMTShard" '
106 + ipfs object patch $EMPTY_HAMT add-link --allow-non-unixfs foo $EMPTY_UNIXFS_DIR
107 + '
108 +
109 + # 4) UnixFS Directory (QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn): allowed
110 + test_expect_success "'ipfs object patch add-link' works on UnixFS Directory nodes" '
111 OUTPUT=$(ipfs object patch $EMPTY_UNIXFS_DIR add-link foo $EMPTY_UNIXFS_DIR) &&
112 ipfs dag stat $OUTPUT
113 '
@@ -122,15 +194,70 @@ test_object_cmd() {
194
195 test_patch_create_path $EMPTY a/b/b/b/b $FILE
196
125 - test_expect_success "can create blank object" '
126 - BLANK=$EMPTY_DIR
197 + test_expect_success "'ipfs object patch add-link --create' rejects non-UnixFS roots" '
198 + test_must_fail ipfs object patch $EMPTY_DIR add-link --create a $FILE
199 '
200
129 - test_patch_create_path $BLANK a $FILE
201 + test_expect_success "'ipfs object patch add-link --create --allow-non-unixfs' works on non-UnixFS roots" '
202 + PCOUT=$(ipfs object patch $EMPTY_DIR add-link --create --allow-non-unixfs a $FILE) &&
203 + ipfs cat "$PCOUT/a" >tpcp_out &&
204 + ipfs cat "$FILE" >tpcp_exp &&
205 + test_cmp tpcp_exp tpcp_out
206 + '
207
208 test_expect_success "create bad path fails" '
209 test_must_fail ipfs object patch $EMPTY add-link --create / $FILE
210 '
211 +
212 + # --- UnixFS validation for 'object patch rm-link' ---
213 + # Same rationale as add-link: dagutils.Editor cannot update UnixFS metadata.
214 +
215 + # 1) Bare dag-pb: rejected by default
216 + test_expect_success "'ipfs object patch rm-link' rejects non-UnixFS dag-pb nodes" '
217 + DAGPB_WITH_LINK=$(ipfs object patch $EMPTY_DIR add-link --allow-non-unixfs foo $EMPTY_UNIXFS_DIR) &&
218 + test_expect_code 1 ipfs object patch $DAGPB_WITH_LINK rm-link foo 2>rmlink_dagpb_err
219 + '
220 +
221 + test_expect_success "rm-link error for non-UnixFS dag-pb has expected message" '
222 + echo "Error: cannot remove links from a non-UnixFS dag-pb node; pass --allow-non-unixfs to skip validation" >rmlink_dagpb_expected &&
223 + test_cmp rmlink_dagpb_expected rmlink_dagpb_err
224 + '
225 +
226 + test_expect_success "'ipfs object patch rm-link --allow-non-unixfs' works on dag-pb nodes" '
227 + ipfs object patch $DAGPB_WITH_LINK rm-link --allow-non-unixfs foo
228 + '
229 +
230 + # 2) UnixFS File: rejected by default
231 + test_expect_success "'ipfs object patch rm-link' rejects UnixFS File nodes" '
232 + FILE_WITH_LINK=$(ipfs object patch $EMPTY_UNIXFS_FILE add-link --allow-non-unixfs foo $EMPTY_UNIXFS_DIR) &&
233 + test_expect_code 1 ipfs object patch $FILE_WITH_LINK rm-link foo 2>rmlink_file_err
234 + '
235 +
236 + test_expect_success "rm-link error for UnixFS File has expected message" '
237 + echo "Error: cannot remove links from a UnixFS File node, only Directory nodes support link removal at the dag-pb level (see https://specs.ipfs.tech/unixfs/)" >rmlink_file_expected &&
238 + test_cmp rmlink_file_expected rmlink_file_err
239 + '
240 +
241 + test_expect_success "'ipfs object patch rm-link --allow-non-unixfs' bypasses check on File nodes" '
242 + ipfs object patch $FILE_WITH_LINK rm-link --allow-non-unixfs foo
243 + '
244 +
245 + # 3) HAMTShard: rejected by default
246 + test_expect_success "'ipfs object patch rm-link' rejects HAMTShard nodes" '
247 + HAMT_WITH_LINK=$(ipfs object patch $EMPTY_HAMT add-link --allow-non-unixfs foo $EMPTY_UNIXFS_DIR) &&
248 + test_expect_code 1 ipfs object patch $HAMT_WITH_LINK rm-link foo 2>rmlink_hamt_err
249 + '
250 +
251 + test_expect_success "rm-link error for HAMTShard has expected message" '
252 + echo "Error: cannot remove links from a HAMTShard at the dag-pb level (would corrupt the HAMT bitfield); use '"'"'ipfs files rm'"'"' instead, or pass --allow-non-unixfs to override" >rmlink_hamt_expected &&
253 + test_cmp rmlink_hamt_expected rmlink_hamt_err
254 + '
255 +
256 + test_expect_success "'ipfs object patch rm-link --allow-non-unixfs' bypasses check on HAMTShard" '
257 + ipfs object patch $HAMT_WITH_LINK rm-link --allow-non-unixfs foo
258 + '
259 +
260 + # 4) UnixFS Directory: allowed (already tested above in existing rm-link tests)
261 }
262
263 # should work offline