From 247816a58fefdadf890eb6948bb09de3cea1b104 Mon Sep 17 00:00:00 2001 From: Lucas Molas Date: Mon, 10 Jan 2022 11:56:04 -0300 Subject: [PATCH 1/5] feat(cmds): files: add new-root command to change the MFS root --- core/commands/files.go | 119 +++++++++++++++++++++++++++++++++++++---- core/node/core.go | 13 +++-- 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/core/commands/files.go b/core/commands/files.go index 3f7e3e6b9a9..d3637396bed 100644 --- a/core/commands/files.go +++ b/core/commands/files.go @@ -13,10 +13,13 @@ import ( humanize "github.com/dustin/go-humanize" "github.com/ipfs/go-ipfs/core" "github.com/ipfs/go-ipfs/core/commands/cmdenv" + "github.com/ipfs/go-ipfs/core/node" + "github.com/ipfs/go-ipfs/repo/fsrepo" bservice "github.com/ipfs/go-blockservice" cid "github.com/ipfs/go-cid" cidenc "github.com/ipfs/go-cidutil/cidenc" + "github.com/ipfs/go-datastore" cmds "github.com/ipfs/go-ipfs-cmds" offline "github.com/ipfs/go-ipfs-exchange-offline" ipld "github.com/ipfs/go-ipld-format" @@ -70,16 +73,17 @@ operations. cmds.BoolOption(filesFlushOptionName, "f", "Flush target and ancestors after write.").WithDefault(true), }, Subcommands: map[string]*cmds.Command{ - "read": filesReadCmd, - "write": filesWriteCmd, - "mv": filesMvCmd, - "cp": filesCpCmd, - "ls": filesLsCmd, - "mkdir": filesMkdirCmd, - "stat": filesStatCmd, - "rm": filesRmCmd, - "flush": filesFlushCmd, - "chcid": filesChcidCmd, + "read": filesReadCmd, + "write": filesWriteCmd, + "mv": filesMvCmd, + "cp": filesCpCmd, + "ls": filesLsCmd, + "mkdir": filesMkdirCmd, + "stat": filesStatCmd, + "rm": filesRmCmd, + "flush": filesFlushCmd, + "chcid": filesChcidCmd, + "replace-root": filesReplaceRoot, }, } @@ -1134,6 +1138,101 @@ Remove files or directories. }, } +var filesReplaceRoot = &cmds.Command{ + Helptext: cmds.HelpText{ + // FIXME(BLOCKING): Somewhere around we should flag that this is an advanced command + // that you normally wouldn't need except in case of filesystem corruption. Where? + Tagline: "Replace the filesystem root.", + ShortDescription: ` + Replace the filesystem root with another CID. + + $ ipfs init + [...] + ipfs cat /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme # <- init dir + [...] + $ ipfs files ls / + [nothing; empty dir] + $ ipfs files stat / --hash + QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn + + # FIXME(BLOCKING): Need the following to somehow "start" the root dir, otherwise + # the replace-root will fail to find the '/local/filesroot' entry in the repo + $ ipfs files cp /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme /file + + $ GOLOG_LOG_LEVEL="info" ipfs files replace-root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc # init dir from before + [...] replaced MFS files root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc [...] + [here we have the CID of the old root to "undo" in case of error] + $ ipfs files ls / + [contents from init dir now set as the root of the filesystem] + about + contact + help + ping + quick-start + readme + security-notes + $ ipfs files replace-root QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn # empty dir from init + $ ipfs files ls / + [nothing; empty dir] +`, + }, + Arguments: []cmds.Argument{ + cmds.StringArg("new-root", true, false, "New root to use."), + }, + // FIXME(BLOCKING): Can/should we do this with the repo running? + NoRemote: true, + Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { + if len(req.Arguments) < 1 { + fmt.Errorf("new root not provided") + } + newFilesRootCid, err := cid.Parse(req.Arguments[0]) + if err != nil { + return fmt.Errorf("files root argument provided %s is not a valid CID: %w", req.Arguments[0], err) + + } + // FIXME(BLOCKING): Check (a) that this CID exists *locally* and (b) + // that it's a dir. + + cfgRoot, err := cmdenv.GetConfigRoot(env) + if err != nil { + return err + } + + repo, err := fsrepo.Open(cfgRoot) + if err != nil { + return err + } + localDS := repo.Datastore() + defer repo.Close() + + filesRootBytes, err := localDS.Get(req.Context, node.FilesRootDatastoreKey) + if err == datastore.ErrNotFound { + return fmt.Errorf("MFS files root %s not found in repo", node.FilesRootDatastoreKey) + } else if err != nil { + return fmt.Errorf("looking for MFS files root: %w", err) + } + filesRootCid, err := cid.Cast(filesRootBytes) + if err != nil { + return fmt.Errorf("casting MFS files root %s as CID: %w", filesRootBytes, err) + } + + err = localDS.Put(req.Context, node.FilesRootDatastoreKey, newFilesRootCid.Bytes()) + if err != nil { + return fmt.Errorf("storing new files root: %w", err) + } + // FIXME(BLOCKING): Do we need this if we're closing the repo at the end + // of the command? Likely not. + err = localDS.Sync(req.Context, node.FilesRootDatastoreKey) + if err != nil { + return fmt.Errorf("syncing new files root: %w", err) + } + + log.Infof("replaced MFS files root %s with %s", filesRootCid, newFilesRootCid) + + return nil + }, +} + func getPrefixNew(req *cmds.Request) (cid.Builder, error) { cidVer, cidVerSet := req.Options[filesCidVersionOptionName].(int) hashFunStr, hashFunSet := req.Options[filesHashOptionName].(string) diff --git a/core/node/core.go b/core/node/core.go index c8305bb610a..410a869871b 100644 --- a/core/node/core.go +++ b/core/node/core.go @@ -29,6 +29,12 @@ import ( "github.com/ipfs/go-ipfs/repo" ) +var FilesRootDatastoreKey datastore.Key + +func init() { + FilesRootDatastoreKey = datastore.NewKey("/local/filesroot") +} + // BlockService creates new blockservice which provides an interface to fetch content-addressable blocks func BlockService(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService { bsvc := blockservice.New(bs, rem) @@ -110,7 +116,6 @@ func Dag(bs blockservice.BlockService) format.DAGService { // Files loads persisted MFS root func Files(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format.DAGService) (*mfs.Root, error) { - dsk := datastore.NewKey("/local/filesroot") pf := func(ctx context.Context, c cid.Cid) error { rootDS := repo.Datastore() if err := rootDS.Sync(ctx, blockstore.BlockPrefix); err != nil { @@ -120,15 +125,15 @@ func Files(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format. return err } - if err := rootDS.Put(ctx, dsk, c.Bytes()); err != nil { + if err := rootDS.Put(ctx, FilesRootDatastoreKey, c.Bytes()); err != nil { return err } - return rootDS.Sync(ctx, dsk) + return rootDS.Sync(ctx, FilesRootDatastoreKey) } var nd *merkledag.ProtoNode ctx := helpers.LifecycleCtx(mctx, lc) - val, err := repo.Datastore().Get(ctx, dsk) + val, err := repo.Datastore().Get(ctx, FilesRootDatastoreKey) switch { case err == datastore.ErrNotFound || val == nil: From cb1212f799b24636da1436d8db772dc7f2325b58 Mon Sep 17 00:00:00 2001 From: Lucas Molas Date: Tue, 11 Jan 2022 11:45:55 -0300 Subject: [PATCH 2/5] fix error return --- core/commands/files.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/commands/files.go b/core/commands/files.go index d3637396bed..b93ea1becd1 100644 --- a/core/commands/files.go +++ b/core/commands/files.go @@ -1183,7 +1183,7 @@ var filesReplaceRoot = &cmds.Command{ NoRemote: true, Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { if len(req.Arguments) < 1 { - fmt.Errorf("new root not provided") + return fmt.Errorf("new root not provided") } newFilesRootCid, err := cid.Parse(req.Arguments[0]) if err != nil { From a0f16cc886cc0010dc925d20f6a04b0c6bd5e48a Mon Sep 17 00:00:00 2001 From: Lucas Molas Date: Tue, 11 Jan 2022 12:10:15 -0300 Subject: [PATCH 3/5] better doc and error around running daemon --- core/commands/files.go | 75 ++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/core/commands/files.go b/core/commands/files.go index b93ea1becd1..fbc08f7e5b7 100644 --- a/core/commands/files.go +++ b/core/commands/files.go @@ -20,6 +20,7 @@ import ( cid "github.com/ipfs/go-cid" cidenc "github.com/ipfs/go-cidutil/cidenc" "github.com/ipfs/go-datastore" + fslock "github.com/ipfs/go-fs-lock" cmds "github.com/ipfs/go-ipfs-cmds" offline "github.com/ipfs/go-ipfs-exchange-offline" ipld "github.com/ipfs/go-ipld-format" @@ -1140,40 +1141,45 @@ Remove files or directories. var filesReplaceRoot = &cmds.Command{ Helptext: cmds.HelpText{ - // FIXME(BLOCKING): Somewhere around we should flag that this is an advanced command - // that you normally wouldn't need except in case of filesystem corruption. Where? Tagline: "Replace the filesystem root.", ShortDescription: ` - Replace the filesystem root with another CID. - - $ ipfs init - [...] - ipfs cat /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme # <- init dir - [...] - $ ipfs files ls / - [nothing; empty dir] - $ ipfs files stat / --hash - QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn - - # FIXME(BLOCKING): Need the following to somehow "start" the root dir, otherwise - # the replace-root will fail to find the '/local/filesroot' entry in the repo - $ ipfs files cp /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme /file - - $ GOLOG_LOG_LEVEL="info" ipfs files replace-root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc # init dir from before - [...] replaced MFS files root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc [...] - [here we have the CID of the old root to "undo" in case of error] - $ ipfs files ls / - [contents from init dir now set as the root of the filesystem] - about - contact - help - ping - quick-start - readme - security-notes - $ ipfs files replace-root QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn # empty dir from init - $ ipfs files ls / - [nothing; empty dir] +Replace the filesystem root with another CID when the filesystem has been corrupted. +`, + LongDescription: ` +Replace the filesystem root with another CID. +This command is meant to run in standalone mode when the daemon isn't running. +It is an advanced command that you normally do *not* want to run except when +the filesystem has been corrupted and the daemon refuses to run. + +FIXME: Add an example of the daemon not running once https://github.com/ipfs/go-ipfs/issues/7183 +is resolved. + +$ ipfs init +[...] +ipfs cat /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme # <- init dir +[...] +$ ipfs files ls / +[nothing; empty dir] +$ ipfs files stat / --hash +QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn +# FIXME(BLOCKING): Need the following to somehow "start" the root dir, otherwise +# the replace-root will fail to find the '/local/filesroot' entry in the repo +$ ipfs files cp /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme /file +$ GOLOG_LOG_LEVEL="info" ipfs files replace-root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc # init dir from before +[...] replaced MFS files root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc [...] +[here we have the CID of the old root to "undo" in case of error] +$ ipfs files ls / +[contents from init dir now set as the root of the filesystem] +about +contact +help +ping +quick-start +readme +security-notes +$ ipfs files replace-root QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn # empty dir from init +$ ipfs files ls / +[nothing; empty dir] `, }, Arguments: []cmds.Argument{ @@ -1200,6 +1206,11 @@ var filesReplaceRoot = &cmds.Command{ repo, err := fsrepo.Open(cfgRoot) if err != nil { + if pathError, ok := err.(*os.PathError); ok { + if _, isLockError := pathError.Unwrap().(fslock.LockedError); isLockError { + return fmt.Errorf("aquiring the repo lock (make sure the daemon isn't running): %w", err) + } + } return err } localDS := repo.Datastore() From 4cf0bbf5c0554359b73a85a1b39e30bd4a77b5a8 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 13 Jan 2026 20:46:55 +0100 Subject: [PATCH 4/5] feat(files): add 'ipfs files chroot' command adds recovery command for corrupted MFS root (issue #10762): - `ipfs files chroot [--confirm] []` replaces the MFS root CID - defaults to empty directory if no CID specified - validates new CID exists locally and is a directory - exports FilesRootDatastoreKey constant for reuse - improved error message shows CID and suggests recovery replaces the previous `replace-root` approach with renamed command following the `ch*` pattern (chcid, chmod, chroot). --- core/commands/commands_test.go | 1 + core/commands/files.go | 284 +++++++++++++++++++-------------- core/node/core.go | 13 +- docs/changelogs/v0.40.md | 17 ++ test/cli/files_test.go | 106 ++++++++++++ 5 files changed, 297 insertions(+), 124 deletions(-) diff --git a/core/commands/commands_test.go b/core/commands/commands_test.go index 23782f209d0..893c7352c05 100644 --- a/core/commands/commands_test.go +++ b/core/commands/commands_test.go @@ -90,6 +90,7 @@ func TestCommands(t *testing.T) { "/files/stat", "/files/write", "/files/chmod", + "/files/chroot", "/files/touch", "/filestore", "/filestore/dups", diff --git a/core/commands/files.go b/core/commands/files.go index 599ea418805..e2e06f00bf8 100644 --- a/core/commands/files.go +++ b/core/commands/files.go @@ -16,6 +16,7 @@ import ( "time" humanize "github.com/dustin/go-humanize" + oldcmds "github.com/ipfs/kubo/commands" "github.com/ipfs/kubo/config" "github.com/ipfs/kubo/core" "github.com/ipfs/kubo/core/commands/cmdenv" @@ -23,6 +24,7 @@ import ( fsrepo "github.com/ipfs/kubo/repo/fsrepo" bservice "github.com/ipfs/boxo/blockservice" + bstore "github.com/ipfs/boxo/blockstore" offline "github.com/ipfs/boxo/exchange/offline" dag "github.com/ipfs/boxo/ipld/merkledag" ft "github.com/ipfs/boxo/ipld/unixfs" @@ -31,7 +33,6 @@ import ( cid "github.com/ipfs/go-cid" cidenc "github.com/ipfs/go-cidutil/cidenc" "github.com/ipfs/go-datastore" - fslock "github.com/ipfs/go-fs-lock" cmds "github.com/ipfs/go-ipfs-cmds" ipld "github.com/ipfs/go-ipld-format" logging "github.com/ipfs/go-log/v2" @@ -124,19 +125,19 @@ performance.`, cmds.BoolOption(filesFlushOptionName, "f", "Flush target and ancestors after write.").WithDefault(true), }, Subcommands: map[string]*cmds.Command{ - "read": filesReadCmd, - "write": filesWriteCmd, - "mv": filesMvCmd, - "cp": filesCpCmd, - "ls": filesLsCmd, - "mkdir": filesMkdirCmd, - "stat": filesStatCmd, - "rm": filesRmCmd, - "flush": filesFlushCmd, - "chcid": filesChcidCmd, - "chmod": filesChmodCmd, - "touch": filesTouchCmd, - "replace-root": filesReplaceRoot, + "read": filesReadCmd, + "write": filesWriteCmd, + "mv": filesMvCmd, + "cp": filesCpCmd, + "ls": filesLsCmd, + "mkdir": filesMkdirCmd, + "stat": filesStatCmd, + "rm": filesRmCmd, + "flush": filesFlushCmd, + "chcid": filesChcidCmd, + "chmod": filesChmodCmd, + "chroot": filesChrootCmd, + "touch": filesTouchCmd, }, } @@ -1366,111 +1367,6 @@ Remove files or directories. }, } -var filesReplaceRoot = &cmds.Command{ - Helptext: cmds.HelpText{ - Tagline: "Replace the MFS root.", - ShortDescription: ` -Replace the filesystem root with another CID when the filesystem. Usually used as recovery method if MFS has been corrupted. -`, - LongDescription: ` -Replace the filesystem root with another CID. -This command is meant to run in standalone mode when the daemon isn't running. -It is an advanced command that you normally do *not* want to run except when -the filesystem has been corrupted and the daemon refuses to run. - -FIXME: Add an example of the daemon not running once https://github.com/ipfs/go-ipfs/issues/7183 -is resolved. - -$ ipfs init -[...] -ipfs cat /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme # <- init dir -[...] -$ ipfs files ls / -[nothing; empty dir] -$ ipfs files stat / --hash -QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn -# FIXME(BLOCKING): Need the following to somehow "start" the root dir, otherwise -# the replace-root will fail to find the '/local/filesroot' entry in the repo -$ ipfs files cp /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme /file -$ GOLOG_LOG_LEVEL="info" ipfs files replace-root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc # init dir from before -[...] replaced MFS files root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc [...] -[here we have the CID of the old root to "undo" in case of error] -$ ipfs files ls / -[contents from init dir now set as the root of the filesystem] -about -contact -help -ping -quick-start -readme -security-notes -$ ipfs files replace-root QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn # empty dir from init -$ ipfs files ls / -[nothing; empty dir] -`, - }, - Arguments: []cmds.Argument{ - cmds.StringArg("new-root", true, false, "New root to use."), - }, - // FIXME(BLOCKING): Can/should we do this with the repo running? - NoRemote: true, - Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { - if len(req.Arguments) < 1 { - return fmt.Errorf("new root not provided") - } - newFilesRootCid, err := cid.Parse(req.Arguments[0]) - if err != nil { - return fmt.Errorf("files root argument provided %s is not a valid CID: %w", req.Arguments[0], err) - - } - // FIXME(BLOCKING): Check (a) that this CID exists *locally* and (b) - // that it's a dir. - - cfgRoot, err := cmdenv.GetConfigRoot(env) - if err != nil { - return err - } - - repo, err := fsrepo.Open(cfgRoot) - if err != nil { - if pathError, ok := err.(*os.PathError); ok { - if _, isLockError := pathError.Unwrap().(fslock.LockedError); isLockError { - return fmt.Errorf("aquiring the repo lock (make sure the daemon isn't running): %w", err) - } - } - return err - } - localDS := repo.Datastore() - defer repo.Close() - - filesRootBytes, err := localDS.Get(req.Context, node.FilesRootDatastoreKey) - if err == datastore.ErrNotFound { - return fmt.Errorf("MFS files root %s not found in repo", node.FilesRootDatastoreKey) - } else if err != nil { - return fmt.Errorf("looking for MFS files root: %w", err) - } - filesRootCid, err := cid.Cast(filesRootBytes) - if err != nil { - return fmt.Errorf("casting MFS files root %s as CID: %w", filesRootBytes, err) - } - - err = localDS.Put(req.Context, node.FilesRootDatastoreKey, newFilesRootCid.Bytes()) - if err != nil { - return fmt.Errorf("storing new files root: %w", err) - } - // FIXME(BLOCKING): Do we need this if we're closing the repo at the end - // of the command? Likely not. - err = localDS.Sync(req.Context, node.FilesRootDatastoreKey) - if err != nil { - return fmt.Errorf("syncing new files root: %w", err) - } - - log.Infof("replaced MFS files root %s with %s", filesRootCid, newFilesRootCid) - - return nil - }, -} - func removePath(filesRoot *mfs.Root, path string, force bool, dashr bool) error { if path == "/" { return fmt.Errorf("cannot delete root") @@ -1758,3 +1654,153 @@ Examples: return mfs.Touch(nd.FilesRoot, path, ts) }, } + +const chrootConfirmOptionName = "confirm" + +var filesChrootCmd = &cmds.Command{ + Status: cmds.Experimental, + Helptext: cmds.HelpText{ + Tagline: "Change the MFS root CID.", + ShortDescription: ` +'ipfs files chroot' changes the root CID used by MFS (Mutable File System). +This is a recovery command for when MFS becomes corrupted and prevents the +daemon from starting. + +When run without a CID argument, resets MFS to an empty directory. + +WARNING: The old MFS root and its unpinned children will be removed during +the next garbage collection. Pin the old root first if you want to preserve. + +This command can only run when the daemon is not running. +`, + LongDescription: ` +'ipfs files chroot' changes the root CID used by MFS (Mutable File System). +This is a recovery command for when MFS becomes corrupted and prevents the +daemon from starting. + +When run without a CID argument, resets MFS to an empty directory. + +WARNING: The old MFS root and its unpinned children will be removed during +the next garbage collection. Pin the old root first if you want to preserve. + +This command can only run when the daemon is not running. + +Examples: + + # Reset MFS to empty directory (recovery from corruption) + $ ipfs files chroot --confirm + + # Restore MFS to a known good directory CID + $ ipfs files chroot --confirm QmYourBackupCID +`, + }, + Arguments: []cmds.Argument{ + cmds.StringArg("cid", false, false, "New root CID (defaults to empty directory if not specified)."), + }, + Options: []cmds.Option{ + cmds.BoolOption(chrootConfirmOptionName, "Confirm this potentially destructive operation."), + }, + NoRemote: true, + Extra: CreateCmdExtras(SetDoesNotUseRepo(true)), + Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { + confirm, _ := req.Options[chrootConfirmOptionName].(bool) + if !confirm { + return errors.New("this is a potentially destructive operation; pass --confirm to proceed") + } + + // Determine new root CID + var newRootCid cid.Cid + if len(req.Arguments) > 0 { + var err error + newRootCid, err = cid.Decode(req.Arguments[0]) + if err != nil { + return fmt.Errorf("invalid CID %q: %w", req.Arguments[0], err) + } + } else { + // Default to empty directory + newRootCid = ft.EmptyDirNode().Cid() + } + + // Get config root to open repo directly + cctx := env.(*oldcmds.Context) + cfgRoot := cctx.ConfigRoot + + // Open repo directly (daemon must not be running) + repo, err := fsrepo.Open(cfgRoot) + if err != nil { + return fmt.Errorf("opening repo (is the daemon running?): %w", err) + } + defer repo.Close() + + localDS := repo.Datastore() + bs := bstore.NewBlockstore(localDS) + + // Check new root exists locally and is a directory + hasBlock, err := bs.Has(req.Context, newRootCid) + if err != nil { + return fmt.Errorf("checking if new root exists: %w", err) + } + if !hasBlock { + // Special case: empty dir is always available (hardcoded in boxo) + emptyDirCid := ft.EmptyDirNode().Cid() + if !newRootCid.Equals(emptyDirCid) { + return fmt.Errorf("new root %s does not exist locally; fetch it first with 'ipfs block get'", newRootCid) + } + } + + // Validate it's a directory (not a file) + if hasBlock { + blk, err := bs.Get(req.Context, newRootCid) + if err != nil { + return fmt.Errorf("reading new root block: %w", err) + } + pbNode, err := dag.DecodeProtobuf(blk.RawData()) + if err != nil { + return fmt.Errorf("new root is not a valid dag-pb node: %w", err) + } + fsNode, err := ft.FSNodeFromBytes(pbNode.Data()) + if err != nil { + return fmt.Errorf("new root is not a valid UnixFS node: %w", err) + } + if fsNode.Type() != ft.TDirectory && fsNode.Type() != ft.THAMTShard { + return fmt.Errorf("new root must be a directory, got %s", fsNode.Type()) + } + } + + // Get old root for display (if exists) + var oldRootStr string + oldRootBytes, err := localDS.Get(req.Context, node.FilesRootDatastoreKey) + if err == nil { + oldRootCid, err := cid.Cast(oldRootBytes) + if err == nil { + oldRootStr = oldRootCid.String() + } + } else if !errors.Is(err, datastore.ErrNotFound) { + return fmt.Errorf("reading current MFS root: %w", err) + } + + // Write new root + err = localDS.Put(req.Context, node.FilesRootDatastoreKey, newRootCid.Bytes()) + if err != nil { + return fmt.Errorf("writing new MFS root: %w", err) + } + + // Build output message + var msg string + if oldRootStr != "" { + msg = fmt.Sprintf("MFS root changed from %s to %s\n", oldRootStr, newRootCid) + msg += fmt.Sprintf("The old root %s will be garbage collected unless pinned.\n", oldRootStr) + } else { + msg = fmt.Sprintf("MFS root set to %s\n", newRootCid) + } + + return cmds.EmitOnce(res, &MessageOutput{Message: msg}) + }, + Type: MessageOutput{}, + Encoders: cmds.EncoderMap{ + cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *MessageOutput) error { + _, err := fmt.Fprint(w, out.Message) + return err + }), + }, +} diff --git a/core/node/core.go b/core/node/core.go index a636a0c5406..06e786f1fdf 100644 --- a/core/node/core.go +++ b/core/node/core.go @@ -30,6 +30,9 @@ import ( "github.com/ipfs/kubo/repo" ) +// FilesRootDatastoreKey is the datastore key for the MFS files root CID. +var FilesRootDatastoreKey = datastore.NewKey("/local/filesroot") + // BlockService creates new blockservice which provides an interface to fetch content-addressable blocks func BlockService(cfg *config.Config) func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService { return func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService { @@ -181,7 +184,6 @@ func Dag(bs blockservice.BlockService) format.DAGService { // Files loads persisted MFS root 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) { return func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format.DAGService, bs blockstore.Blockstore, prov DHTProvider) (*mfs.Root, error) { - dsk := datastore.NewKey("/local/filesroot") pf := func(ctx context.Context, c cid.Cid) error { rootDS := repo.Datastore() 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 return err } - if err := rootDS.Put(ctx, dsk, c.Bytes()); err != nil { + if err := rootDS.Put(ctx, FilesRootDatastoreKey, c.Bytes()); err != nil { return err } - return rootDS.Sync(ctx, dsk) + return rootDS.Sync(ctx, FilesRootDatastoreKey) } var nd *merkledag.ProtoNode ctx := helpers.LifecycleCtx(mctx, lc) - val, err := repo.Datastore().Get(ctx, dsk) + val, err := repo.Datastore().Get(ctx, FilesRootDatastoreKey) switch { case errors.Is(err, datastore.ErrNotFound): @@ -243,7 +245,8 @@ func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo root, err := mfs.NewRoot(ctx, dag, nd, pf, prov) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to initialize MFS root from %s stored at %s: %w. "+ + "If corrupted, use 'ipfs files chroot' to reset (see --help)", nd.Cid(), FilesRootDatastoreKey, err) } lc.Append(fx.Hook{ diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index adc7f252c86..dcdf9a27d11 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -17,6 +17,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. - [Improved `ipfs dag stat` output](#improved-ipfs-dag-stat-output) - [Skip bad keys when listing](#skip_bad_keys_when_listing) - [Accelerated DHT Client and Provide Sweep now work together](#accelerated-dht-client-and-provide-sweep-now-work-together) + - [๐Ÿ”ง Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root) - [๐Ÿ“ฆ๏ธ Dependency updates](#-dependency-updates) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -96,6 +97,22 @@ Change the `ipfs key list` behavior to log an error and continue listing keys wh 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. +#### ๐Ÿ”ง Recovery from corrupted MFS root + +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`. + +The new `ipfs files chroot` command lets you reset the MFS (Mutable File System) root or restore it to a known valid CID: + +```console +# Reset MFS to an empty directory +$ ipfs files chroot --confirm + +# Or restore from a previously saved directory CID +$ ipfs files chroot --confirm QmYourBackupCID +``` + +See `ipfs files chroot --help` for details. + #### ๐Ÿ“ฆ๏ธ Dependency updates - update `go-libp2p` to [v0.46.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.46.0) diff --git a/test/cli/files_test.go b/test/cli/files_test.go index 4760c23aaba..c2d226f9011 100644 --- a/test/cli/files_test.go +++ b/test/cli/files_test.go @@ -353,3 +353,109 @@ func TestFilesNoFlushLimit(t *testing.T) { assert.Contains(t, res.Stderr.String(), "reached limit of 5 unflushed MFS operations") }) } + +func TestFilesChroot(t *testing.T) { + t.Parallel() + + // Known CIDs for testing + emptyDirCid := "QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn" + + t.Run("requires --confirm flag", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init() + // Don't start daemon - chroot runs offline + + res := node.RunIPFS("files", "chroot") + require.NotNil(t, res.ExitErr) + assert.NotEqual(t, 0, res.ExitErr.ExitCode()) + assert.Contains(t, res.Stderr.String(), "pass --confirm to proceed") + }) + + t.Run("resets to empty directory", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init() + + // Start daemon to create MFS state + node.StartDaemon() + node.IPFS("files", "mkdir", "/testdir") + node.StopDaemon() + + // Reset MFS to empty - should exit 0 + res := node.RunIPFS("files", "chroot", "--confirm") + assert.Nil(t, res.ExitErr, "expected exit code 0") + assert.Contains(t, res.Stdout.String(), emptyDirCid) + + // Verify daemon starts and MFS is empty + node.StartDaemon() + defer node.StopDaemon() + lsRes := node.IPFS("files", "ls", "/") + assert.Empty(t, lsRes.Stdout.Trimmed()) + }) + + t.Run("replaces with valid directory CID", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init() + + // Start daemon to add content + node.StartDaemon() + node.IPFS("files", "mkdir", "/mydir") + // Create a temp file for content + tempFile := filepath.Join(node.Dir, "testfile.txt") + require.NoError(t, os.WriteFile(tempFile, []byte("hello"), 0644)) + node.IPFS("files", "write", "--create", "/mydir/file.txt", tempFile) + statRes := node.IPFS("files", "stat", "--hash", "/mydir") + dirCid := statRes.Stdout.Trimmed() + node.StopDaemon() + + // Reset to empty first + node.IPFS("files", "chroot", "--confirm") + + // Set root to the saved directory - should exit 0 + res := node.RunIPFS("files", "chroot", "--confirm", dirCid) + assert.Nil(t, res.ExitErr, "expected exit code 0") + assert.Contains(t, res.Stdout.String(), dirCid) + + // Verify content + node.StartDaemon() + defer node.StopDaemon() + readRes := node.IPFS("files", "read", "/file.txt") + assert.Equal(t, "hello", readRes.Stdout.Trimmed()) + }) + + t.Run("fails with non-existent CID", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init() + + res := node.RunIPFS("files", "chroot", "--confirm", "bafybeibdxtd5thfoitjmnfhxhywokebwdmwnuqgkzjjdjhwjz7qh77777a") + require.NotNil(t, res.ExitErr) + assert.NotEqual(t, 0, res.ExitErr.ExitCode()) + assert.Contains(t, res.Stderr.String(), "does not exist locally") + }) + + t.Run("fails with file CID", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init() + + // Add a file to get a file CID + node.StartDaemon() + fileCid := node.IPFSAddStr("hello world") + node.StopDaemon() + + // Try to set file as root - should fail with non-zero exit + res := node.RunIPFS("files", "chroot", "--confirm", fileCid) + require.NotNil(t, res.ExitErr) + assert.NotEqual(t, 0, res.ExitErr.ExitCode()) + assert.Contains(t, res.Stderr.String(), "must be a directory") + }) + + t.Run("fails while daemon is running", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon() + defer node.StopDaemon() + + res := node.RunIPFS("files", "chroot", "--confirm") + require.NotNil(t, res.ExitErr) + assert.NotEqual(t, 0, res.ExitErr.ExitCode()) + assert.Contains(t, res.Stderr.String(), "opening repo") + }) +} From c22f1edf214004c06708c881c3fe47ab932ec1c2 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 13 Jan 2026 21:26:51 +0100 Subject: [PATCH 5/5] chore(files): remove duplicate description in chroot command remove LongDescription and keep only ShortDescription with examples, following the majority pattern in the codebase (81% of commands use only ShortDescription). --- core/commands/files.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/core/commands/files.go b/core/commands/files.go index e2e06f00bf8..6d0d708fcf5 100644 --- a/core/commands/files.go +++ b/core/commands/files.go @@ -1671,18 +1671,6 @@ When run without a CID argument, resets MFS to an empty directory. WARNING: The old MFS root and its unpinned children will be removed during the next garbage collection. Pin the old root first if you want to preserve. -This command can only run when the daemon is not running. -`, - LongDescription: ` -'ipfs files chroot' changes the root CID used by MFS (Mutable File System). -This is a recovery command for when MFS becomes corrupted and prevents the -daemon from starting. - -When run without a CID argument, resets MFS to an empty directory. - -WARNING: The old MFS root and its unpinned children will be removed during -the next garbage collection. Pin the old root first if you want to preserve. - This command can only run when the daemon is not running. Examples: