@cryptotaxi247 / kubo / commits / 39c609b3d

feat(mfs): chroot command to change the root (#8648)

Co-authored-by: Marcin Rataj <lidel@lidel.org>

Lucas Molas committed Jan 13, 2026 at 17:47 UTC 39c609b3db9b470e6c53e6936ef41d53d829aef0
5 files changed +288 -17
core/commands/commands_test.go
+1
@@ -90,6 +90,7 @@ func TestCommands(t *testing.T) {
90 "/files/stat",
91 "/files/write",
92 "/files/chmod",
93 + "/files/chroot",
94 "/files/touch",
95 "/filestore",
96 "/filestore/dups",
core/commands/files.go
+156 -12
@@ -16,11 +16,15 @@ import (
16 "time"
17
18 humanize "github.com/dustin/go-humanize"
19 + oldcmds "github.com/ipfs/kubo/commands"
20 "github.com/ipfs/kubo/config"
21 "github.com/ipfs/kubo/core"
22 "github.com/ipfs/kubo/core/commands/cmdenv"
23 + "github.com/ipfs/kubo/core/node"
24 + fsrepo "github.com/ipfs/kubo/repo/fsrepo"
25
26 bservice "github.com/ipfs/boxo/blockservice"
27 + bstore "github.com/ipfs/boxo/blockstore"
28 offline "github.com/ipfs/boxo/exchange/offline"
29 dag "github.com/ipfs/boxo/ipld/merkledag"
30 ft "github.com/ipfs/boxo/ipld/unixfs"
@@ -28,6 +32,7 @@ import (
32 "github.com/ipfs/boxo/path"
33 cid "github.com/ipfs/go-cid"
34 cidenc "github.com/ipfs/go-cidutil/cidenc"
35 + "github.com/ipfs/go-datastore"
36 cmds "github.com/ipfs/go-ipfs-cmds"
37 ipld "github.com/ipfs/go-ipld-format"
38 logging "github.com/ipfs/go-log/v2"
@@ -120,18 +125,19 @@ performance.`,
125 cmds.BoolOption(filesFlushOptionName, "f", "Flush target and ancestors after write.").WithDefault(true),
126 },
127 Subcommands: map[string]*cmds.Command{
123 - "read": filesReadCmd,
124 - "write": filesWriteCmd,
125 - "mv": filesMvCmd,
126 - "cp": filesCpCmd,
127 - "ls": filesLsCmd,
128 - "mkdir": filesMkdirCmd,
129 - "stat": filesStatCmd,
130 - "rm": filesRmCmd,
131 - "flush": filesFlushCmd,
132 - "chcid": filesChcidCmd,
133 - "chmod": filesChmodCmd,
134 - "touch": filesTouchCmd,
128 + "read": filesReadCmd,
129 + "write": filesWriteCmd,
130 + "mv": filesMvCmd,
131 + "cp": filesCpCmd,
132 + "ls": filesLsCmd,
133 + "mkdir": filesMkdirCmd,
134 + "stat": filesStatCmd,
135 + "rm": filesRmCmd,
136 + "flush": filesFlushCmd,
137 + "chcid": filesChcidCmd,
138 + "chmod": filesChmodCmd,
139 + "chroot": filesChrootCmd,
140 + "touch": filesTouchCmd,
141 },
142 }
143
@@ -1648,3 +1654,141 @@ Examples:
1654 return mfs.Touch(nd.FilesRoot, path, ts)
1655 },
1656 }
1657 +
1658 +const chrootConfirmOptionName = "confirm"
1659 +
1660 +var filesChrootCmd = &cmds.Command{
1661 + Status: cmds.Experimental,
1662 + Helptext: cmds.HelpText{
1663 + Tagline: "Change the MFS root CID.",
1664 + ShortDescription: `
1665 +'ipfs files chroot' changes the root CID used by MFS (Mutable File System).
1666 +This is a recovery command for when MFS becomes corrupted and prevents the
1667 +daemon from starting.
1668 +
1669 +When run without a CID argument, resets MFS to an empty directory.
1670 +
1671 +WARNING: The old MFS root and its unpinned children will be removed during
1672 +the next garbage collection. Pin the old root first if you want to preserve.
1673 +
1674 +This command can only run when the daemon is not running.
1675 +
1676 +Examples:
1677 +
1678 + # Reset MFS to empty directory (recovery from corruption)
1679 + $ ipfs files chroot --confirm
1680 +
1681 + # Restore MFS to a known good directory CID
1682 + $ ipfs files chroot --confirm QmYourBackupCID
1683 +`,
1684 + },
1685 + Arguments: []cmds.Argument{
1686 + cmds.StringArg("cid", false, false, "New root CID (defaults to empty directory if not specified)."),
1687 + },
1688 + Options: []cmds.Option{
1689 + cmds.BoolOption(chrootConfirmOptionName, "Confirm this potentially destructive operation."),
1690 + },
1691 + NoRemote: true,
1692 + Extra: CreateCmdExtras(SetDoesNotUseRepo(true)),
1693 + Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
1694 + confirm, _ := req.Options[chrootConfirmOptionName].(bool)
1695 + if !confirm {
1696 + return errors.New("this is a potentially destructive operation; pass --confirm to proceed")
1697 + }
1698 +
1699 + // Determine new root CID
1700 + var newRootCid cid.Cid
1701 + if len(req.Arguments) > 0 {
1702 + var err error
1703 + newRootCid, err = cid.Decode(req.Arguments[0])
1704 + if err != nil {
1705 + return fmt.Errorf("invalid CID %q: %w", req.Arguments[0], err)
1706 + }
1707 + } else {
1708 + // Default to empty directory
1709 + newRootCid = ft.EmptyDirNode().Cid()
1710 + }
1711 +
1712 + // Get config root to open repo directly
1713 + cctx := env.(*oldcmds.Context)
1714 + cfgRoot := cctx.ConfigRoot
1715 +
1716 + // Open repo directly (daemon must not be running)
1717 + repo, err := fsrepo.Open(cfgRoot)
1718 + if err != nil {
1719 + return fmt.Errorf("opening repo (is the daemon running?): %w", err)
1720 + }
1721 + defer repo.Close()
1722 +
1723 + localDS := repo.Datastore()
1724 + bs := bstore.NewBlockstore(localDS)
1725 +
1726 + // Check new root exists locally and is a directory
1727 + hasBlock, err := bs.Has(req.Context, newRootCid)
1728 + if err != nil {
1729 + return fmt.Errorf("checking if new root exists: %w", err)
1730 + }
1731 + if !hasBlock {
1732 + // Special case: empty dir is always available (hardcoded in boxo)
1733 + emptyDirCid := ft.EmptyDirNode().Cid()
1734 + if !newRootCid.Equals(emptyDirCid) {
1735 + return fmt.Errorf("new root %s does not exist locally; fetch it first with 'ipfs block get'", newRootCid)
1736 + }
1737 + }
1738 +
1739 + // Validate it's a directory (not a file)
1740 + if hasBlock {
1741 + blk, err := bs.Get(req.Context, newRootCid)
1742 + if err != nil {
1743 + return fmt.Errorf("reading new root block: %w", err)
1744 + }
1745 + pbNode, err := dag.DecodeProtobuf(blk.RawData())
1746 + if err != nil {
1747 + return fmt.Errorf("new root is not a valid dag-pb node: %w", err)
1748 + }
1749 + fsNode, err := ft.FSNodeFromBytes(pbNode.Data())
1750 + if err != nil {
1751 + return fmt.Errorf("new root is not a valid UnixFS node: %w", err)
1752 + }
1753 + if fsNode.Type() != ft.TDirectory && fsNode.Type() != ft.THAMTShard {
1754 + return fmt.Errorf("new root must be a directory, got %s", fsNode.Type())
1755 + }
1756 + }
1757 +
1758 + // Get old root for display (if exists)
1759 + var oldRootStr string
1760 + oldRootBytes, err := localDS.Get(req.Context, node.FilesRootDatastoreKey)
1761 + if err == nil {
1762 + oldRootCid, err := cid.Cast(oldRootBytes)
1763 + if err == nil {
1764 + oldRootStr = oldRootCid.String()
1765 + }
1766 + } else if !errors.Is(err, datastore.ErrNotFound) {
1767 + return fmt.Errorf("reading current MFS root: %w", err)
1768 + }
1769 +
1770 + // Write new root
1771 + err = localDS.Put(req.Context, node.FilesRootDatastoreKey, newRootCid.Bytes())
1772 + if err != nil {
1773 + return fmt.Errorf("writing new MFS root: %w", err)
1774 + }
1775 +
1776 + // Build output message
1777 + var msg string
1778 + if oldRootStr != "" {
1779 + msg = fmt.Sprintf("MFS root changed from %s to %s\n", oldRootStr, newRootCid)
1780 + msg += fmt.Sprintf("The old root %s will be garbage collected unless pinned.\n", oldRootStr)
1781 + } else {
1782 + msg = fmt.Sprintf("MFS root set to %s\n", newRootCid)
1783 + }
1784 +
1785 + return cmds.EmitOnce(res, &MessageOutput{Message: msg})
1786 + },
1787 + Type: MessageOutput{},
1788 + Encoders: cmds.EncoderMap{
1789 + cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *MessageOutput) error {
1790 + _, err := fmt.Fprint(w, out.Message)
1791 + return err
1792 + }),
1793 + },
1794 +}
core/node/core.go
+8 -5
@@ -30,6 +30,9 @@ import (
30 "github.com/ipfs/kubo/repo"
31 )
32
33 +// FilesRootDatastoreKey is the datastore key for the MFS files root CID.
34 +var FilesRootDatastoreKey = datastore.NewKey("/local/filesroot")
35 +
36 // BlockService creates new blockservice which provides an interface to fetch content-addressable blocks
37 func BlockService(cfg *config.Config) func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService {
38 return func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService {
@@ -181,7 +184,6 @@ func Dag(bs blockservice.BlockService) format.DAGService {
184 // Files loads persisted MFS root
185 func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format.DAGService, bs blockstore.Blockstore, prov DHTProvider) (*mfs.Root, error) {
186 return func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format.DAGService, bs blockstore.Blockstore, prov DHTProvider) (*mfs.Root, error) {
184 - dsk := datastore.NewKey("/local/filesroot")
187 pf := func(ctx context.Context, c cid.Cid) error {
188 rootDS := repo.Datastore()
189 if err := rootDS.Sync(ctx, blockstore.BlockPrefix); err != nil {
@@ -191,15 +193,15 @@ func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo
193 return err
194 }
195
194 - if err := rootDS.Put(ctx, dsk, c.Bytes()); err != nil {
196 + if err := rootDS.Put(ctx, FilesRootDatastoreKey, c.Bytes()); err != nil {
197 return err
198 }
197 - return rootDS.Sync(ctx, dsk)
199 + return rootDS.Sync(ctx, FilesRootDatastoreKey)
200 }
201
202 var nd *merkledag.ProtoNode
203 ctx := helpers.LifecycleCtx(mctx, lc)
202 - val, err := repo.Datastore().Get(ctx, dsk)
204 + val, err := repo.Datastore().Get(ctx, FilesRootDatastoreKey)
205
206 switch {
207 case errors.Is(err, datastore.ErrNotFound):
@@ -243,7 +245,8 @@ func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo
245
246 root, err := mfs.NewRoot(ctx, dag, nd, pf, prov)
247 if err != nil {
246 - return nil, err
248 + return nil, fmt.Errorf("failed to initialize MFS root from %s stored at %s: %w. "+
249 + "If corrupted, use 'ipfs files chroot' to reset (see --help)", nd.Cid(), FilesRootDatastoreKey, err)
250 }
251
252 lc.Append(fx.Hook{
docs/changelogs/v0.40.md
+17
@@ -17,6 +17,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
17 - [Improved `ipfs dag stat` output](#improved-ipfs-dag-stat-output)
18 - [Skip bad keys when listing](#skip_bad_keys_when_listing)
19 - [Accelerated DHT Client and Provide Sweep now work together](#accelerated-dht-client-and-provide-sweep-now-work-together)
20 + - [🔧 Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root)
21 - [📦️ Dependency updates](#-dependency-updates)
22 - [📝 Changelog](#-changelog)
23 - [👨‍👩‍👧‍👦 Contributors](#-contributors)
@@ -96,6 +97,22 @@ Change the `ipfs key list` behavior to log an error and continue listing keys wh
97
98 Previously, provide operations could start before the Accelerated DHT Client discovered enough peers, causing sweep mode to lose its efficiency benefits. Now, providing waits for the initial network crawl (about 10 minutes). Your content will be properly distributed across DHT regions after initial DHT map is created. Check `ipfs provide stat` to see when providing begins.
99
100 +#### 🔧 Recovery from corrupted MFS root
101 +
102 +If your daemon fails to start because the MFS root is not a directory (due to misconfiguration, operational error, or disk corruption), you can now recover without deleting and recreating your repository in a new `IPFS_PATH`.
103 +
104 +The new `ipfs files chroot` command lets you reset the MFS (Mutable File System) root or restore it to a known valid CID:
105 +
106 +```console
107 +# Reset MFS to an empty directory
108 +$ ipfs files chroot --confirm
109 +
110 +# Or restore from a previously saved directory CID
111 +$ ipfs files chroot --confirm QmYourBackupCID
112 +```
113 +
114 +See `ipfs files chroot --help` for details.
115 +
116 #### 📦️ Dependency updates
117
118 - update `go-libp2p` to [v0.46.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.46.0)
test/cli/files_test.go
+106
@@ -353,3 +353,109 @@ func TestFilesNoFlushLimit(t *testing.T) {
353 assert.Contains(t, res.Stderr.String(), "reached limit of 5 unflushed MFS operations")
354 })
355 }
356 +
357 +func TestFilesChroot(t *testing.T) {
358 + t.Parallel()
359 +
360 + // Known CIDs for testing
361 + emptyDirCid := "QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn"
362 +
363 + t.Run("requires --confirm flag", func(t *testing.T) {
364 + t.Parallel()
365 + node := harness.NewT(t).NewNode().Init()
366 + // Don't start daemon - chroot runs offline
367 +
368 + res := node.RunIPFS("files", "chroot")
369 + require.NotNil(t, res.ExitErr)
370 + assert.NotEqual(t, 0, res.ExitErr.ExitCode())
371 + assert.Contains(t, res.Stderr.String(), "pass --confirm to proceed")
372 + })
373 +
374 + t.Run("resets to empty directory", func(t *testing.T) {
375 + t.Parallel()
376 + node := harness.NewT(t).NewNode().Init()
377 +
378 + // Start daemon to create MFS state
379 + node.StartDaemon()
380 + node.IPFS("files", "mkdir", "/testdir")
381 + node.StopDaemon()
382 +
383 + // Reset MFS to empty - should exit 0
384 + res := node.RunIPFS("files", "chroot", "--confirm")
385 + assert.Nil(t, res.ExitErr, "expected exit code 0")
386 + assert.Contains(t, res.Stdout.String(), emptyDirCid)
387 +
388 + // Verify daemon starts and MFS is empty
389 + node.StartDaemon()
390 + defer node.StopDaemon()
391 + lsRes := node.IPFS("files", "ls", "/")
392 + assert.Empty(t, lsRes.Stdout.Trimmed())
393 + })
394 +
395 + t.Run("replaces with valid directory CID", func(t *testing.T) {
396 + t.Parallel()
397 + node := harness.NewT(t).NewNode().Init()
398 +
399 + // Start daemon to add content
400 + node.StartDaemon()
401 + node.IPFS("files", "mkdir", "/mydir")
402 + // Create a temp file for content
403 + tempFile := filepath.Join(node.Dir, "testfile.txt")
404 + require.NoError(t, os.WriteFile(tempFile, []byte("hello"), 0644))
405 + node.IPFS("files", "write", "--create", "/mydir/file.txt", tempFile)
406 + statRes := node.IPFS("files", "stat", "--hash", "/mydir")
407 + dirCid := statRes.Stdout.Trimmed()
408 + node.StopDaemon()
409 +
410 + // Reset to empty first
411 + node.IPFS("files", "chroot", "--confirm")
412 +
413 + // Set root to the saved directory - should exit 0
414 + res := node.RunIPFS("files", "chroot", "--confirm", dirCid)
415 + assert.Nil(t, res.ExitErr, "expected exit code 0")
416 + assert.Contains(t, res.Stdout.String(), dirCid)
417 +
418 + // Verify content
419 + node.StartDaemon()
420 + defer node.StopDaemon()
421 + readRes := node.IPFS("files", "read", "/file.txt")
422 + assert.Equal(t, "hello", readRes.Stdout.Trimmed())
423 + })
424 +
425 + t.Run("fails with non-existent CID", func(t *testing.T) {
426 + t.Parallel()
427 + node := harness.NewT(t).NewNode().Init()
428 +
429 + res := node.RunIPFS("files", "chroot", "--confirm", "bafybeibdxtd5thfoitjmnfhxhywokebwdmwnuqgkzjjdjhwjz7qh77777a")
430 + require.NotNil(t, res.ExitErr)
431 + assert.NotEqual(t, 0, res.ExitErr.ExitCode())
432 + assert.Contains(t, res.Stderr.String(), "does not exist locally")
433 + })
434 +
435 + t.Run("fails with file CID", func(t *testing.T) {
436 + t.Parallel()
437 + node := harness.NewT(t).NewNode().Init()
438 +
439 + // Add a file to get a file CID
440 + node.StartDaemon()
441 + fileCid := node.IPFSAddStr("hello world")
442 + node.StopDaemon()
443 +
444 + // Try to set file as root - should fail with non-zero exit
445 + res := node.RunIPFS("files", "chroot", "--confirm", fileCid)
446 + require.NotNil(t, res.ExitErr)
447 + assert.NotEqual(t, 0, res.ExitErr.ExitCode())
448 + assert.Contains(t, res.Stderr.String(), "must be a directory")
449 + })
450 +
451 + t.Run("fails while daemon is running", func(t *testing.T) {
452 + t.Parallel()
453 + node := harness.NewT(t).NewNode().Init().StartDaemon()
454 + defer node.StopDaemon()
455 +
456 + res := node.RunIPFS("files", "chroot", "--confirm")
457 + require.NotNil(t, res.ExitErr)
458 + assert.NotEqual(t, 0, res.ExitErr.ExitCode())
459 + assert.Contains(t, res.Stderr.String(), "opening repo")
460 + })
461 +}