Repository: restic/restic
Pull request #4550 (2023-10-30 11:13:38)

Ls ncdu
What does this PR change? What problem does it solve?
-----------------------------------------------------
NCDU (NCurses Disk Usage) is a tool to analyse disk usage of directories.
It has an option to save a directory tree and analyse it later.
This patch adds an output option to the ls command for the NCDU format.

Use `restic ls latest --ncdu | ncdu -f -`



Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
no

Created issue: #4549


Checklist
---------

- [X] I have read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
- [X] I have [enabled maintainer edits](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork).
- [x] I have added tests for all code changes.
- [x] I have added documentation for relevant changes (in the manual).
- [X] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).
- [X] I have run `gofmt` on the code in all commits.
- [X] All commit messages are formatted in the same style as [the other commits in the repo](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
- [X] I'm done! This pull request is ready for review.


jvandenbroek: I am intrigued by this patch, so had to try it out ;-) But when a snapshot contains multiple paths it doesn't work correctly and specifying a specific path results in the following error:

```
panic: cannot find suitable parent directory

goroutine 1 [running]:
main.lsNodeNcdu({0x137a120, 0xc00007c030}, 0xc0000f57d0, 0xc0000f5840, {0xc00636cc30, 0xb}, 0xc00636f7c0)
        github.com/restic/restic/cmd/restic/cmd_ls.go:175 +0x505
main.runLs.func6({0xc00636cc30?, 0xb?}, 0xc0005e8720?)
        github.com/restic/restic/cmd/restic/cmd_ls.go:293 +0x45
main.runLs.func9({0x34, 0xc5, 0xfb, 0x92, 0x0, 0x0, 0x15, 0x62, 0x14, 0x5, ...}, ...)
        github.com/restic/restic/cmd/restic/cmd_ls.go:331 +0x90
github.com/restic/restic/internal/walker.walk({0x1387d28, 0xc0000ad5e0}, {0x137a200, 0xc0004b8410}, {0xc00636caa0, 0x4}, {0x34, 0xc5, 0xfb, 0x92, ...}, ...)
        github.com/restic/restic/internal/walker/walker.go:103 +0x2e7
github.com/restic/restic/internal/walker.walk({0x1387d28, 0xc0000ad5e0}, {0x137a200, 0xc0004b8410}, {0x1372ab0, 0x1}, {0x3b, 0xd6, 0x76, 0xc2, ...}, ...)
        github.com/restic/restic/internal/walker/walker.go:122 +0x467
github.com/restic/restic/internal/walker.Walk({0x1387d28, 0xc0000ad5e0}, {0x137a200, 0xc0004b8410}, {0x3b, 0xd6, 0x76, 0xc2, 0x41, 0x28, ...}, ...)
        github.com/restic/restic/internal/walker/walker.go:51 +0x1ef
main.runLs({_, _}, {0x0, {{}, {0x0, 0x0, 0x0}, {0x0, 0x0, 0x0}, ...}, ...}, ...)
        github.com/restic/restic/cmd/restic/cmd_ls.go:321 +0x78d
main.glob..func15(0xc00019b500?, {0xc0003e9340?, 0x4?, 0x11afcb5?})
        github.com/restic/restic/cmd/restic/cmd_ls.go:47 +0x87
github.com/spf13/cobra.(*Command).execute(0x1b49f00, {0xc0003e92d0, 0x7, 0x7})
        github.com/spf13/cobra@v1.7.0/command.go:940 +0x87c
github.com/spf13/cobra.(*Command).ExecuteC(0x1b4e6e0)
        github.com/spf13/cobra@v1.7.0/command.go:1068 +0x3a5
github.com/spf13/cobra.(*Command).Execute(...)
        github.com/spf13/cobra@v1.7.0/command.go:992
github.com/spf13/cobra.(*Command).ExecuteContext(...)
        github.com/spf13/cobra@v1.7.0/command.go:985
main.main()
        github.com/restic/restic/cmd/restic/main.go:109 +0x1a5
Error importing file on line 2:2: expected ','.
```

MichaelEischer: I've extended the `walker` to also inform about leaving a directory. This allows significantly simplifying the JSON printing code. The different output formats are now also abstracted using a common interface such that no more workarounds are necessary to print the NCDU json.

@jvandenbroek The code should now also work when specifying a path.

remaining TODOs:
- [ ] add tests for walker.LeaveDir
- [ ] add documentation

Patch:
diff --git a/changelog/unreleased/issue-4549 b/changelog/unreleased/issue-4549
new file mode 100644
index 000000000..4829a9881
--- /dev/null
+++ b/changelog/unreleased/issue-4549
@@ -0,0 +1,11 @@
+Enhancement: Add `--ncdu` option to `ls` command
+
+NCDU (NCurses Disk Usage) is a tool to analyse disk usage of directories.
+It has an option to save a directory tree and analyse it later.
+The `ls` command now supports the `--ncdu` option which outputs information
+about a snapshot in the NCDU format.
+
+You can use it as follows: `restic ls latest --ncdu | ncdu -f -`
+
+https://github.com/restic/restic/issues/4549
+https://github.com/restic/restic/pull/4550
diff --git a/cmd/restic/cmd_backup.go b/cmd/restic/cmd_backup.go
index 635179417..2ea2a4ec5 100644
--- a/cmd/restic/cmd_backup.go
+++ b/cmd/restic/cmd_backup.go
@@ -411,7 +411,7 @@ func collectTargets(opts BackupOptions, args []string) (targets []string, err er
 
 // parent returns the ID of the parent snapshot. If there is none, nil is
 // returned.
-func findParentSnapshot(ctx context.Context, repo restic.ListerLoaderUnpacked, opts BackupOptions, targets []string, timeStampLimit time.Time) (*restic.Snapshot, error) {
+func findParentSnapshot(ctx context.Context, repo restic.Repository, opts BackupOptions, targets []string, timeStampLimit time.Time) (*restic.Snapshot, error) {
 	if opts.Force {
 		return nil, nil
 	}
diff --git a/cmd/restic/cmd_debug.go b/cmd/restic/cmd_debug.go
index a87e7a0c5..49efb904b 100644
--- a/cmd/restic/cmd_debug.go
+++ b/cmd/restic/cmd_debug.go
@@ -137,7 +137,7 @@ func printPacks(ctx context.Context, repo *repository.Repository, wr io.Writer)
 	})
 }
 
-func dumpIndexes(ctx context.Context, repo restic.ListerLoaderUnpacked, wr io.Writer) error {
+func dumpIndexes(ctx context.Context, repo restic.Repository, wr io.Writer) error {
 	return index.ForAllIndexes(ctx, repo, repo, func(id restic.ID, idx *index.Index, oldFormat bool, err error) error {
 		Printf("index_id: %v\n", id)
 		if err != nil {
diff --git a/cmd/restic/cmd_diff.go b/cmd/restic/cmd_diff.go
index 06f2be2ae..7878613e1 100644
--- a/cmd/restic/cmd_diff.go
+++ b/cmd/restic/cmd_diff.go
@@ -61,7 +61,7 @@ func init() {
 	f.BoolVar(&diffOptions.ShowMetadata, "metadata", false, "print changes in metadata")
 }
 
-func loadSnapshot(ctx context.Context, be restic.Lister, repo restic.LoaderUnpacked, desc string) (*restic.Snapshot, string, error) {
+func loadSnapshot(ctx context.Context, be restic.Lister, repo restic.Repository, desc string) (*restic.Snapshot, string, error) {
 	sn, subfolder, err := restic.FindSnapshot(ctx, be, repo, desc)
 	if err != nil {
 		return nil, "", errors.Fatal(err.Error())
@@ -71,7 +71,7 @@ func loadSnapshot(ctx context.Context, be restic.Lister, repo restic.LoaderUnpac
 
 // Comparer collects all things needed to compare two snapshots.
 type Comparer struct {
-	repo        restic.BlobLoader
+	repo        restic.Repository
 	opts        DiffOptions
 	printChange func(change *Change)
 }
@@ -147,7 +147,7 @@ type DiffStatsContainer struct {
 }
 
 // updateBlobs updates the blob counters in the stats struct.
-func updateBlobs(repo restic.Loader, blobs restic.BlobSet, stats *DiffStat) {
+func updateBlobs(repo restic.Repository, blobs restic.BlobSet, stats *DiffStat) {
 	for h := range blobs {
 		switch h.Type {
 		case restic.DataBlob:
diff --git a/cmd/restic/cmd_dump.go b/cmd/restic/cmd_dump.go
index e72b51fa2..e6020d847 100644
--- a/cmd/restic/cmd_dump.go
+++ b/cmd/restic/cmd_dump.go
@@ -67,7 +67,7 @@ func splitPath(p string) []string {
 	return append(s, f)
 }
 
-func printFromTree(ctx context.Context, tree *restic.Tree, repo restic.BlobLoader, prefix string, pathComponents []string, d *dump.Dumper) error {
+func printFromTree(ctx context.Context, tree *restic.Tree, repo restic.Repository, prefix string, pathComponents []string, d *dump.Dumper) error {
 	// If we print / we need to assume that there are multiple nodes at that
 	// level in the tree.
 	if pathComponents[0] == "" {
diff --git a/cmd/restic/cmd_find.go b/cmd/restic/cmd_find.go
index 33fff864f..04e6ae3dd 100644
--- a/cmd/restic/cmd_find.go
+++ b/cmd/restic/cmd_find.go
@@ -260,7 +260,7 @@ func (f *Finder) findInSnapshot(ctx context.Context, sn *restic.Snapshot) error
 	}
 
 	f.out.newsn = sn
-	return walker.Walk(ctx, f.repo, *sn.Tree, func(parentTreeID restic.ID, nodepath string, node *restic.Node, err error) error {
+	return walker.Walk(ctx, f.repo, *sn.Tree, walker.WalkVisitor{ProcessNode: func(parentTreeID restic.ID, nodepath string, node *restic.Node, err error) error {
 		if err != nil {
 			debug.Log("Error loading tree %v: %v", parentTreeID, err)
 
@@ -327,7 +327,7 @@ func (f *Finder) findInSnapshot(ctx context.Context, sn *restic.Snapshot) error
 		debug.Log("    found match\n")
 		f.out.PrintPattern(nodepath, node)
 		return nil
-	})
+	}})
 }
 
 func (f *Finder) findIDs(ctx context.Context, sn *restic.Snapshot) error {
@@ -338,7 +338,7 @@ func (f *Finder) findIDs(ctx context.Context, sn *restic.Snapshot) error {
 	}
 
 	f.out.newsn = sn
-	return walker.Walk(ctx, f.repo, *sn.Tree, func(parentTreeID restic.ID, nodepath string, node *restic.Node, err error) error {
+	return walker.Walk(ctx, f.repo, *sn.Tree, walker.WalkVisitor{ProcessNode: func(parentTreeID restic.ID, nodepath string, node *restic.Node, err error) error {
 		if err != nil {
 			debug.Log("Error loading tree %v: %v", parentTreeID, err)
 
@@ -388,7 +388,7 @@ func (f *Finder) findIDs(ctx context.Context, sn *restic.Snapshot) error {
 		}
 
 		return nil
-	})
+	}})
 }
 
 var errAllPacksFound = errors.New("all packs found")
diff --git a/cmd/restic/cmd_ls.go b/cmd/restic/cmd_ls.go
index d30e2819c..f412546ae 100644
--- a/cmd/restic/cmd_ls.go
+++ b/cmd/restic/cmd_ls.go
@@ -3,6 +3,8 @@ package main
 import (
 	"context"
 	"encoding/json"
+	"fmt"
+	"io"
 	"os"
 	"strings"
 	"time"
@@ -51,6 +53,7 @@ type LsOptions struct {
 	restic.SnapshotFilter
 	Recursive     bool
 	HumanReadable bool
+	Ncdu          bool
 }
 
 var lsOptions LsOptions
@@ -63,16 +66,47 @@ func init() {
 	flags.BoolVarP(&lsOptions.ListLong, "long", "l", false, "use a long listing format showing size and mode")
 	flags.BoolVar(&lsOptions.Recursive, "recursive", false, "include files in subfolders of the listed directories")
 	flags.BoolVar(&lsOptions.HumanReadable, "human-readable", false, "print sizes in human readable format")
+	flags.BoolVar(&lsOptions.Ncdu, "ncdu", false, "output NCDU export format (pipe into 'ncdu -f -')")
 }
 
-type lsSnapshot struct {
-	*restic.Snapshot
-	ID         *restic.ID `json:"id"`
-	ShortID    string     `json:"short_id"`
-	StructType string     `json:"struct_type"` // "snapshot"
+type lsPrinter interface {
+	Snapshot(sn *restic.Snapshot)
+	Node(path string, node *restic.Node)
+	LeaveDir(path string)
+	Close()
+}
+
+type jsonLsPrinter struct {
+	enc *json.Encoder
+}
+
+func (p *jsonLsPrinter) Snapshot(sn *restic.Snapshot) {
+	type lsSnapshot struct {
+		*restic.Snapshot
+		ID         *restic.ID `json:"id"`
+		ShortID    string     `json:"short_id"`
+		StructType string     `json:"struct_type"` // "snapshot"
+	}
+
+	err := p.enc.Encode(lsSnapshot{
+		Snapshot:   sn,
+		ID:         sn.ID(),
+		ShortID:    sn.ID().Str(),
+		StructType: "snapshot",
+	})
+	if err != nil {
+		Warnf("JSON encode failed: %v\n", err)
+	}
 }
 
 // Print node in our custom JSON format, followed by a newline.
+func (p *jsonLsPrinter) Node(path string, node *restic.Node) {
+	err := lsNodeJSON(p.enc, path, node)
+	if err != nil {
+		Warnf("JSON encode failed: %v\n", err)
+	}
+}
+
 func lsNodeJSON(enc *json.Encoder, path string, node *restic.Node) error {
 	n := &struct {
 		Name        string      `json:"name"`
@@ -114,10 +148,117 @@ func lsNodeJSON(enc *json.Encoder, path string, node *restic.Node) error {
 	return enc.Encode(n)
 }
 
+func (p *jsonLsPrinter) LeaveDir(_ string) {}
+func (p *jsonLsPrinter) Close()            {}
+
+type ncduLsPrinter struct {
+	out   io.Writer
+	depth int
+}
+
+// lsSnapshotNcdu prints a restic snapshot in Ncdu save format.
+// It opens the JSON list. Nodes are added with lsNodeNcdu and the list is closed by lsCloseNcdu.
+// Format documentation: https://dev.yorhel.nl/ncdu/jsonfmt
+func (p *ncduLsPrinter) Snapshot(sn *restic.Snapshot) {
+	const NcduMajorVer = 1
+	const NcduMinorVer = 2
+
+	snapshotBytes, err := json.Marshal(sn)
+	if err != nil {
+		Warnf("JSON encode failed: %v\n", err)
+	}
+	p.depth++
+	fmt.Fprintf(p.out, "[%d, %d, %s", NcduMajorVer, NcduMinorVer, string(snapshotBytes))
+}
+
+func lsNcduNode(_ string, node *restic.Node) ([]byte, error) {
+	type NcduNode struct {
+		Name   string `json:"name"`
+		Asize  uint64 `json:"asize"`
+		Dsize  uint64 `json:"dsize"`
+		Dev    uint64 `json:"dev"`
+		Ino    uint64 `json:"ino"`
+		NLink  uint64 `json:"nlink"`
+		NotReg bool   `json:"notreg"`
+		UID    uint32 `json:"uid"`
+		GID    uint32 `json:"gid"`
+		Mode   uint16 `json:"mode"`
+		Mtime  int64  `json:"mtime"`
+	}
+
+	outNode := NcduNode{
+		Name:   node.Name,
+		Asize:  node.Size,
+		Dsize:  node.Size,
+		Dev:    node.DeviceID,
+		Ino:    node.Inode,
+		NLink:  node.Links,
+		NotReg: node.Type != "dir" && node.Type != "file",
+		UID:    node.UID,
+		GID:    node.GID,
+		Mode:   uint16(node.Mode & os.ModePerm),
+		Mtime:  node.ModTime.Unix(),
+	}
+	//  bits according to inode(7) manpage
+	if node.Mode&os.ModeSetuid != 0 {
+		outNode.Mode |= 0o4000
+	}
+	if node.Mode&os.ModeSetgid != 0 {
+		outNode.Mode |= 0o2000
+	}
+	if node.Mode&os.ModeSticky != 0 {
+		outNode.Mode |= 0o1000
+	}
+
+	return json.Marshal(outNode)
+}
+
+func (p *ncduLsPrinter) Node(path string, node *restic.Node) {
+	out, err := lsNcduNode(path, node)
+	if err != nil {
+		Warnf("JSON encode failed: %v\n", err)
+	}
+
+	if node.Type == "dir" {
+		fmt.Fprintf(p.out, ",\n%s[\n%s%s", strings.Repeat("  ", p.depth), strings.Repeat("  ", p.depth+1), string(out))
+		p.depth++
+	} else {
+		fmt.Fprintf(p.out, ",\n%s%s", strings.Repeat("  ", p.depth), string(out))
+	}
+}
+
+func (p *ncduLsPrinter) LeaveDir(_ string) {
+	p.depth--
+	fmt.Fprintf(p.out, "\n%s]", strings.Repeat("  ", p.depth))
+}
+
+func (p *ncduLsPrinter) Close() {
+	fmt.Fprint(p.out, "\n]\n")
+}
+
+type textLsPrinter struct {
+	dirs          []string
+	ListLong      bool
+	HumanReadable bool
+}
+
+func (p *textLsPrinter) Snapshot(sn *restic.Snapshot) {
+	Verbosef("%v filtered by %v:\n", sn, p.dirs)
+}
+func (p *textLsPrinter) Node(path string, node *restic.Node) {
+	Printf("%s\n", formatNode(path, node, p.ListLong, p.HumanReadable))
+}
+
+func (p *textLsPrinter) LeaveDir(_ string) {}
+func (p *textLsPrinter) Close()            {}
+
 func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []string) error {
 	if len(args) == 0 {
 		return errors.Fatal("no snapshot ID specified, specify snapshot ID or use special ID 'latest'")
 	}
+	if opts.Ncdu && gopts.JSON {
+		return errors.Fatal("only either '--json' or '--ncdu' can be specified")
+	}
 
 	// extract any specific directories to walk
 	var dirs []string
@@ -179,38 +320,21 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 		return err
 	}
 
-	var (
-		printSnapshot func(sn *restic.Snapshot)
-		printNode     func(path string, node *restic.Node)
-	)
+	var printer lsPrinter
 
 	if gopts.JSON {
-		enc := json.NewEncoder(globalOptions.stdout)
-
-		printSnapshot = func(sn *restic.Snapshot) {
-			err := enc.Encode(lsSnapshot{
-				Snapshot:   sn,
-				ID:         sn.ID(),
-				ShortID:    sn.ID().Str(),
-				StructType: "snapshot",
-			})
-			if err != nil {
-				Warnf("JSON encode failed: %v\n", err)
-			}
+		printer = &jsonLsPrinter{
+			enc: json.NewEncoder(globalOptions.stdout),
 		}
-
-		printNode = func(path string, node *restic.Node) {
-			err := lsNodeJSON(enc, path, node)
-			if err != nil {
-				Warnf("JSON encode failed: %v\n", err)
-			}
+	} else if opts.Ncdu {
+		printer = &ncduLsPrinter{
+			out: globalOptions.stdout,
 		}
 	} else {
-		printSnapshot = func(sn *restic.Snapshot) {
-			Verbosef("%v filtered by %v:\n", sn, dirs)
-		}
-		printNode = func(path string, node *restic.Node) {
-			Printf("%s\n", formatNode(path, node, opts.ListLong, opts.HumanReadable))
+		printer = &textLsPrinter{
+			dirs:          dirs,
+			ListLong:      opts.ListLong,
+			HumanReadable: opts.HumanReadable,
 		}
 	}
 
@@ -228,9 +352,9 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 		return err
 	}
 
-	printSnapshot(sn)
+	printer.Snapshot(sn)
 
-	err = walker.Walk(ctx, repo, *sn.Tree, func(_ restic.ID, nodepath string, node *restic.Node, err error) error {
+	processNode := func(_ restic.ID, nodepath string, node *restic.Node, err error) error {
 		if err != nil {
 			return err
 		}
@@ -240,7 +364,7 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 
 		if withinDir(nodepath) {
 			// if we're within a dir, print the node
-			printNode(nodepath, node)
+			printer.Node(nodepath, node)
 
 			// if recursive listing is requested, signal the walker that it
 			// should continue walking recursively
@@ -261,11 +385,22 @@ func runLs(ctx context.Context, opts LsOptions, gopts GlobalOptions, args []stri
 			return walker.ErrSkipNode
 		}
 		return nil
+	}
+
+	err = walker.Walk(ctx, repo, *sn.Tree, walker.WalkVisitor{
+		ProcessNode: processNode,
+		LeaveDir: func(path string) {
+			// the root path `/` has no corresponding node and is thus also skipped by processNode
+			if withinDir(path) && path != "/" {
+				printer.LeaveDir(path)
+			}
+		},
 	})
 
 	if err != nil {
 		return err
 	}
 
+	printer.Close()
 	return nil
 }
diff --git a/cmd/restic/cmd_recover.go b/cmd/restic/cmd_recover.go
index 499abdf80..ae6aff740 100644
--- a/cmd/restic/cmd_recover.go
+++ b/cmd/restic/cmd_recover.go
@@ -158,7 +158,7 @@ func runRecover(ctx context.Context, gopts GlobalOptions) error {
 
 }
 
-func createSnapshot(ctx context.Context, name, hostname string, tags []string, repo restic.SaverUnpacked, tree *restic.ID) error {
+func createSnapshot(ctx context.Context, name, hostname string, tags []string, repo restic.Repository, tree *restic.ID) error {
 	sn, err := restic.NewSnapshot([]string{name}, tags, hostname, time.Now())
 	if err != nil {
 		return errors.Fatalf("unable to save snapshot: %v", err)
diff --git a/cmd/restic/cmd_stats.go b/cmd/restic/cmd_stats.go
index 87c3f8d22..f7febf4d0 100644
--- a/cmd/restic/cmd_stats.go
+++ b/cmd/restic/cmd_stats.go
@@ -189,7 +189,7 @@ func runStats(ctx context.Context, opts StatsOptions, gopts GlobalOptions, args
 	return nil
 }
 
-func statsWalkSnapshot(ctx context.Context, snapshot *restic.Snapshot, repo restic.Loader, opts StatsOptions, stats *statsContainer) error {
+func statsWalkSnapshot(ctx context.Context, snapshot *restic.Snapshot, repo restic.Repository, opts StatsOptions, stats *statsContainer) error {
 	if snapshot.Tree == nil {
 		return fmt.Errorf("snapshot %s has nil tree", snapshot.ID().Str())
 	}
@@ -203,7 +203,9 @@ func statsWalkSnapshot(ctx context.Context, snapshot *restic.Snapshot, repo rest
 	}
 
 	hardLinkIndex := restorer.NewHardlinkIndex[struct{}]()
-	err := walker.Walk(ctx, repo, *snapshot.Tree, statsWalkTree(repo, opts, stats, hardLinkIndex))
+	err := walker.Walk(ctx, repo, *snapshot.Tree, walker.WalkVisitor{
+		ProcessNode: statsWalkTree(repo, opts, stats, hardLinkIndex),
+	})
 	if err != nil {
 		return fmt.Errorf("walking tree %s: %v", *snapshot.Tree, err)
 	}
@@ -211,7 +213,7 @@ func statsWalkSnapshot(ctx context.Context, snapshot *restic.Snapshot, repo rest
 	return nil
 }
 
-func statsWalkTree(repo restic.Loader, opts StatsOptions, stats *statsContainer, hardLinkIndex *restorer.HardlinkIndex[struct{}]) walker.WalkFunc {
+func statsWalkTree(repo restic.Repository, opts StatsOptions, stats *statsContainer, hardLinkIndex *restorer.HardlinkIndex[struct{}]) walker.WalkFunc {
 	return func(parentTreeID restic.ID, npath string, node *restic.Node, nodeErr error) error {
 		if nodeErr != nil {
 			return nodeErr
@@ -363,7 +365,7 @@ func statsDebug(ctx context.Context, repo restic.Repository) error {
 	return nil
 }
 
-func statsDebugFileType(ctx context.Context, repo restic.Lister, tpe restic.FileType) (*sizeHistogram, error) {
+func statsDebugFileType(ctx context.Context, repo restic.Repository, tpe restic.FileType) (*sizeHistogram, error) {
 	hist := newSizeHistogram(2 * repository.MaxPackSize)
 	err := repo.List(ctx, tpe, func(id restic.ID, size int64) error {
 		hist.Add(uint64(size))
diff --git a/doc/045_working_with_repos.rst b/doc/045_working_with_repos.rst
index d74c9c240..48e5985dc 100644
--- a/doc/045_working_with_repos.rst
+++ b/doc/045_working_with_repos.rst
@@ -82,6 +82,76 @@ Furthermore you can group the output by the same filters (host, paths, tags):
     1 snapshots
 
 
+Listing files in a snapshot
+===========================
+
+To get a list of the files in a specific snapshot you can use the ``ls`` command:
+
+.. code-block:: console
+
+    $ restic ls 073a90db
+
+    snapshot 073a90db of [/home/user/work.txt] filtered by [] at 2024-01-21 16:51:18.474558607 +0100 CET):
+    /home
+    /home/user
+    /home/user/work.txt
+
+The special snapshot ID ``latest`` can be used to list files and directories of the latest snapshot in the repository.
+The ``--host`` flag can be used in conjunction to select the latest snapshot originating from a certain host only.
+
+.. code-block:: console
+
+    $ restic ls --host kasimir latest
+
+    snapshot 073a90db of [/home/user/work.txt] filtered by [] at 2024-01-21 16:51:18.474558607 +0100 CET):
+    /home
+    /home/user
+    /home/user/work.txt
+
+By default, ``ls`` prints all files in a snapshot.
+
+File listings can optionally be filtered by directories. Any positional arguments after the snapshot ID are interpreted
+as absolute directory paths, and only files inside those directories will be listed. Files in subdirectories are not
+listed when filtering by directories. If the ``--recursive`` flag is used, then subdirectories are also included.
+Any directory paths specified must be absolute (starting with a path separator); paths use the forward slash '/'
+as separator.
+
+.. code-block:: console
+
+    $ restic ls latest /home
+    
+    snapshot 073a90db of [/home/user/work.txt] filtered by [/home] at 2024-01-21 16:51:18.474558607 +0100 CET):
+    /home
+    /home/user
+
+.. code-block:: console
+
+    $ restic ls --recursive latest /home
+
+    snapshot 073a90db of [/home/user/work.txt] filtered by [/home] at 2024-01-21 16:51:18.474558607 +0100 CET):
+    /home
+    /home/user
+    /home/user/work.txt
+
+To show more details about the files in a snapshot, you can use the ``--long`` option.  The colums include
+file permissions, UID, GID, file size, modification time and file path. For scripting usage, the
+``ls`` command supports the ``--json`` flag; the JSON output format is described at :ref:`ls json`.
+
+.. code-block:: console
+
+    $ restic ls --long latest
+
+    snapshot 073a90db of [/home/user/work.txt] filtered by [] at 2024-01-21 16:51:18.474558607 +0100 CET):
+    drwxr-xr-x     0     0      0 2024-01-21 16:50:52 /home
+    drwxr-xr-x     0     0      0 2024-01-21 16:51:03 /home/user
+    -rw-r--r--     0     0     18 2024-01-21 16:51:03 /home/user/work.txt
+
+NCDU (NCurses Disk Usage) is a tool to analyse disk usage of directories. The ``ls`` command supports
+outputting information about a snapshot in the NCDU format using the ``--ncdu`` option.
+
+You can use it as follows: ``restic ls latest --ncdu | ncdu -f -``
+
+
 Copying snapshots between repositories
 ======================================
 
@@ -242,6 +312,7 @@ Currently, rewriting the hostname and the time of the backup is supported.
 This is possible using the ``rewrite`` command with the option ``--new-host`` followed by the desired new hostname or the option ``--new-time`` followed by the desired new timestamp.
 
 .. code-block:: console
+
     $ restic rewrite --new-host newhost --new-time "1999-01-01 11:11:11"
 
     repository b7dbade3 opened (version 2, compression level auto)
diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst
index f46572209..7279ee614 100644
--- a/doc/075_scripting.rst
+++ b/doc/075_scripting.rst
@@ -409,6 +409,8 @@ The ``key list`` command returns an array of objects with the following structur
 +--------------+------------------------------------+
 
 
+.. _ls json:
+
 ls
 --
 
diff --git a/internal/checker/checker.go b/internal/checker/checker.go
index df865cb41..e6a7a9035 100644
--- a/internal/checker/checker.go
+++ b/internal/checker/checker.go
@@ -14,6 +14,7 @@ import (
 	"github.com/minio/sha256-simd"
 	"github.com/restic/restic/internal/backend"
 	"github.com/restic/restic/internal/backend/s3"
+	"github.com/restic/restic/internal/cache"
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/hashing"
@@ -240,8 +241,17 @@ func IsOrphanedPack(err error) bool {
 }
 
 func isS3Legacy(b backend.Backend) bool {
-	be := backend.AsBackend[*s3.Backend](b)
-	return be != nil && be.Layout.Name() == "s3legacy"
+	// unwrap cache
+	if be, ok := b.(*cache.Backend); ok {
+		b = be.Backend
+	}
+
+	be, ok := b.(*s3.Backend)
+	if !ok {
+		return false
+	}
+
+	return be.Layout.Name() == "s3legacy"
 }
 
 // Packs checks that all packs referenced in the index are still available and
@@ -352,7 +362,7 @@ func (c *Checker) checkTreeWorker(ctx context.Context, trees <-chan restic.TreeI
 	}
 }
 
-func loadSnapshotTreeIDs(ctx context.Context, lister restic.Lister, repo restic.LoaderUnpacked) (ids restic.IDs, errs []error) {
+func loadSnapshotTreeIDs(ctx context.Context, lister restic.Lister, repo restic.Repository) (ids restic.IDs, errs []error) {
 	err := restic.ForAllSnapshots(ctx, lister, repo, nil, func(id restic.ID, sn *restic.Snapshot, err error) error {
 		if err != nil {
 			errs = append(errs, err)
diff --git a/internal/dump/common.go b/internal/dump/common.go
index 7a8d95ae9..88b59e689 100644
--- a/internal/dump/common.go
+++ b/internal/dump/common.go
@@ -16,11 +16,11 @@ import (
 type Dumper struct {
 	cache  *bloblru.Cache
 	format string
-	repo   restic.BlobLoader
+	repo   restic.Repository
 	w      io.Writer
 }
 
-func New(format string, repo restic.BlobLoader, w io.Writer) *Dumper {
+func New(format string, repo restic.Repository, w io.Writer) *Dumper {
 	return &Dumper{
 		cache:  bloblru.New(64 << 20),
 		format: format,
@@ -47,7 +47,7 @@ func (d *Dumper) DumpTree(ctx context.Context, tree *restic.Tree, rootPath strin
 	}
 }
 
-func sendTrees(ctx context.Context, repo restic.BlobLoader, tree *restic.Tree, rootPath string, ch chan *restic.Node) {
+func sendTrees(ctx context.Context, repo restic.Repository, tree *restic.Tree, rootPath string, ch chan *restic.Node) {
 	defer close(ch)
 
 	for _, root := range tree.Nodes {
@@ -58,7 +58,7 @@ func sendTrees(ctx context.Context, repo restic.BlobLoader, tree *restic.Tree, r
 	}
 }
 
-func sendNodes(ctx context.Context, repo restic.BlobLoader, root *restic.Node, ch chan *restic.Node) error {
+func sendNodes(ctx context.Context, repo restic.Repository, root *restic.Node, ch chan *restic.Node) error {
 	select {
 	case ch <- root:
 	case <-ctx.Done():
@@ -70,7 +70,7 @@ func sendNodes(ctx context.Context, repo restic.BlobLoader, root *restic.Node, c
 		return nil
 	}
 
-	err := walker.Walk(ctx, repo, *root.Subtree, func(_ restic.ID, nodepath string, node *restic.Node, err error) error {
+	err := walker.Walk(ctx, repo, *root.Subtree, walker.WalkVisitor{ProcessNode: func(_ restic.ID, nodepath string, node *restic.Node, err error) error {
 		if err != nil {
 			return err
 		}
@@ -91,7 +91,7 @@ func sendNodes(ctx context.Context, repo restic.BlobLoader, root *restic.Node, c
 		}
 
 		return nil
-	})
+	}})
 
 	return err
 }
diff --git a/internal/fuse/dir.go b/internal/fuse/dir.go
index 763a9640c..c5aaf6f52 100644
--- a/internal/fuse/dir.go
+++ b/internal/fuse/dir.go
@@ -58,7 +58,7 @@ func unwrapCtxCanceled(err error) error {
 
 // replaceSpecialNodes replaces nodes with name "." and "/" by their contents.
 // Otherwise, the node is returned.
-func replaceSpecialNodes(ctx context.Context, repo restic.BlobLoader, node *restic.Node) ([]*restic.Node, error) {
+func replaceSpecialNodes(ctx context.Context, repo restic.Repository, node *restic.Node) ([]*restic.Node, error) {
 	if node.Type != "dir" || node.Subtree == nil {
 		return []*restic.Node{node}, nil
 	}
diff --git a/internal/index/index_parallel.go b/internal/index/index_parallel.go
index 3c16d049b..d505d756e 100644
--- a/internal/index/index_parallel.go
+++ b/internal/index/index_parallel.go
@@ -11,7 +11,7 @@ import (
 // ForAllIndexes loads all index files in parallel and calls the given callback.
 // It is guaranteed that the function is not run concurrently. If the callback
 // returns an error, this function is cancelled and also returns that error.
-func ForAllIndexes(ctx context.Context, lister restic.Lister, repo restic.ListerLoaderUnpacked,
+func ForAllIndexes(ctx context.Context, lister restic.Lister, repo restic.Repository,
 	fn func(id restic.ID, index *Index, oldFormat bool, err error) error) error {
 
 	// decoding an index can take quite some time such that this can be both CPU- or IO-bound
diff --git a/internal/restic/lock.go b/internal/restic/lock.go
index d98f8ec94..2d7e04f87 100644
--- a/internal/restic/lock.go
+++ b/internal/restic/lock.go
@@ -341,8 +341,8 @@ func (l *Lock) checkExistence(ctx context.Context) (bool, error) {
 
 	exists := false
 
-	err := l.repo.List(ctx, LockFile, func(id ID, size int64) error {
-		if id.Equal(*l.lockID) {
+	err := l.repo.Backend().List(ctx, LockFile, func(fi backend.FileInfo) error {
+		if fi.Name == l.lockID.String() {
 			exists = true
 		}
 		return nil
@@ -379,7 +379,7 @@ func init() {
 }
 
 // LoadLock loads and unserializes a lock from a repository.
-func LoadLock(ctx context.Context, repo LoaderUnpacked, id ID) (*Lock, error) {
+func LoadLock(ctx context.Context, repo Repository, id ID) (*Lock, error) {
 	lock := &Lock{}
 	if err := LoadJSONUnpacked(ctx, repo, LockFile, id, lock); err != nil {
 		return nil, err
@@ -429,7 +429,7 @@ func RemoveAllLocks(ctx context.Context, repo Repository) (uint, error) {
 // It is guaranteed that the function is not run concurrently. If the
 // callback returns an error, this function is cancelled and also returns that error.
 // If a lock ID is passed via excludeID, it will be ignored.
-func ForAllLocks(ctx context.Context, repo ListerLoaderUnpacked, excludeIDs IDSet, fn func(ID, *Lock, error) error) error {
+func ForAllLocks(ctx context.Context, repo Repository, excludeIDs IDSet, fn func(ID, *Lock, error) error) error {
 	var m sync.Mutex
 
 	// For locks decoding is nearly for free, thus just assume were only limited by IO
diff --git a/internal/restic/node.go b/internal/restic/node.go
index 1d5bb51af..7edc41ce8 100644
--- a/internal/restic/node.go
+++ b/internal/restic/node.go
@@ -142,7 +142,7 @@ func (node Node) GetExtendedAttribute(a string) []byte {
 }
 
 // CreateAt creates the node at the given path but does NOT restore node meta data.
-func (node *Node) CreateAt(ctx context.Context, path string, repo BlobLoader) error {
+func (node *Node) CreateAt(ctx context.Context, path string, repo Repository) error {
 	debug.Log("create node %v at %v", node.Name, path)
 
 	switch node.Type {
@@ -264,7 +264,7 @@ func (node Node) createDirAt(path string) error {
 	return nil
 }
 
-func (node Node) createFileAt(ctx context.Context, path string, repo BlobLoader) error {
+func (node Node) createFileAt(ctx context.Context, path string, repo Repository) error {
 	f, err := fs.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
 	if err != nil {
 		return errors.WithStack(err)
@@ -284,7 +284,7 @@ func (node Node) createFileAt(ctx context.Context, path string, repo BlobLoader)
 	return nil
 }
 
-func (node Node) writeNodeContent(ctx context.Context, repo BlobLoader, f *os.File) error {
+func (node Node) writeNodeContent(ctx context.Context, repo Repository, f *os.File) error {
 	var buf []byte
 	for _, id := range node.Content {
 		buf, err := repo.LoadBlob(ctx, DataBlob, id, buf)
diff --git a/internal/restic/repository.go b/internal/restic/repository.go
index 66cc22ea9..1c6c8d39d 100644
--- a/internal/restic/repository.go
+++ b/internal/restic/repository.go
@@ -113,8 +113,3 @@ type MasterIndex interface {
 type Lister interface {
 	List(ctx context.Context, t FileType, fn func(ID, int64) error) error
 }
-
-type ListerLoaderUnpacked interface {
-	Lister
-	LoaderUnpacked
-}
diff --git a/internal/walker/walker.go b/internal/walker/walker.go
index aba2e39e5..091b05489 100644
--- a/internal/walker/walker.go
+++ b/internal/walker/walker.go
@@ -23,12 +23,20 @@ var ErrSkipNode = errors.New("skip this node")
 // tree are skipped.
 type WalkFunc func(parentTreeID restic.ID, path string, node *restic.Node, nodeErr error) (err error)
 
+type WalkVisitor struct {
+	// If the node is a `dir`, it will be entered afterwards unless `ErrSkipNode`
+	// was returned. This function is mandatory
+	ProcessNode WalkFunc
+	// Optional callback
+	LeaveDir func(path string)
+}
+
 // Walk calls walkFn recursively for each node in root. If walkFn returns an
 // error, it is passed up the call stack. The trees in ignoreTrees are not
 // walked. If walkFn ignores trees, these are added to the set.
-func Walk(ctx context.Context, repo restic.BlobLoader, root restic.ID, walkFn WalkFunc) error {
+func Walk(ctx context.Context, repo restic.BlobLoader, root restic.ID, visitor WalkVisitor) error {
 	tree, err := restic.LoadTree(ctx, repo, root)
-	err = walkFn(root, "/", nil, err)
+	err = visitor.ProcessNode(root, "/", nil, err)
 
 	if err != nil {
 		if err == ErrSkipNode {
@@ -37,13 +45,13 @@ func Walk(ctx context.Context, repo restic.BlobLoader, root restic.ID, walkFn Wa
 		return err
 	}
 
-	return walk(ctx, repo, "/", root, tree, walkFn)
+	return walk(ctx, repo, "/", root, tree, visitor)
 }
 
 // walk recursively traverses the tree, ignoring subtrees when the ID of the
 // subtree is in ignoreTrees. If err is nil and ignore is true, the subtree ID
 // will be added to ignoreTrees by walk.
-func walk(ctx context.Context, repo restic.BlobLoader, prefix string, parentTreeID restic.ID, tree *restic.Tree, walkFn WalkFunc) (err error) {
+func walk(ctx context.Context, repo restic.BlobLoader, prefix string, parentTreeID restic.ID, tree *restic.Tree, visitor WalkVisitor) (err error) {
 	sort.Slice(tree.Nodes, func(i, j int) bool {
 		return tree.Nodes[i].Name < tree.Nodes[j].Name
 	})
@@ -56,11 +64,11 @@ func walk(ctx context.Context, repo restic.BlobLoader, prefix string, parentTree
 		}
 
 		if node.Type != "dir" {
-			err := walkFn(parentTreeID, p, node, nil)
+			err := visitor.ProcessNode(parentTreeID, p, node, nil)
 			if err != nil {
 				if err == ErrSkipNode {
 					// skip the remaining entries in this tree
-					return nil
+					break
 				}
 
 				return err
@@ -74,18 +82,22 @@ func walk(ctx context.Context, repo restic.BlobLoader, prefix string, parentTree
 		}
 
 		subtree, err := restic.LoadTree(ctx, repo, *node.Subtree)
-		err = walkFn(parentTreeID, p, node, err)
+		err = visitor.ProcessNode(parentTreeID, p, node, err)
 		if err != nil {
 			if err == ErrSkipNode {
 				continue
 			}
 		}
 
-		err = walk(ctx, repo, p, *node.Subtree, subtree, walkFn)
+		err = walk(ctx, repo, p, *node.Subtree, subtree, visitor)
 		if err != nil {
 			return err
 		}
 	}
 
+	if visitor.LeaveDir != nil {
+		visitor.LeaveDir(prefix)
+	}
+
 	return nil
 }


Test patch:
diff --git a/cmd/restic/cmd_ls_integration_test.go b/cmd/restic/cmd_ls_integration_test.go
index 39bf9c3b0..1b3c964e4 100644
--- a/cmd/restic/cmd_ls_integration_test.go
+++ b/cmd/restic/cmd_ls_integration_test.go
@@ -2,18 +2,46 @@ package main
 
 import (
 	"context"
+	"encoding/json"
+	"path/filepath"
 	"strings"
 	"testing"
 
 	rtest "github.com/restic/restic/internal/test"
 )
 
-func testRunLs(t testing.TB, gopts GlobalOptions, snapshotID string) []string {
+func testRunLsWithOpts(t testing.TB, gopts GlobalOptions, opts LsOptions, args []string) []byte {
 	buf, err := withCaptureStdout(func() error {
 		gopts.Quiet = true
-		opts := LsOptions{}
-		return runLs(context.TODO(), opts, gopts, []string{snapshotID})
+		return runLs(context.TODO(), opts, gopts, args)
 	})
 	rtest.OK(t, err)
-	return strings.Split(buf.String(), "\n")
+	return buf.Bytes()
+}
+
+func testRunLs(t testing.TB, gopts GlobalOptions, snapshotID string) []string {
+	out := testRunLsWithOpts(t, gopts, LsOptions{}, []string{snapshotID})
+	return strings.Split(string(out), "\n")
+}
+
+func assertIsValidJSON(t *testing.T, data []byte) {
+	// Sanity check: output must be valid JSON.
+	var v interface{}
+	err := json.Unmarshal(data, &v)
+	rtest.OK(t, err)
+}
+
+func TestRunLsNcdu(t *testing.T) {
+	env, cleanup := withTestEnvironment(t)
+	defer cleanup()
+
+	testRunInit(t, env.gopts)
+	opts := BackupOptions{}
+	testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, opts, env.gopts)
+
+	ncdu := testRunLsWithOpts(t, env.gopts, LsOptions{Ncdu: true}, []string{"latest"})
+	assertIsValidJSON(t, ncdu)
+
+	ncdu = testRunLsWithOpts(t, env.gopts, LsOptions{Ncdu: true}, []string{"latest", "/testdata"})
+	assertIsValidJSON(t, ncdu)
 }
diff --git a/cmd/restic/cmd_ls_test.go b/cmd/restic/cmd_ls_test.go
index 8a4fa51ee..41c235eab 100644
--- a/cmd/restic/cmd_ls_test.go
+++ b/cmd/restic/cmd_ls_test.go
@@ -11,78 +11,94 @@ import (
 	rtest "github.com/restic/restic/internal/test"
 )
 
-func TestLsNodeJSON(t *testing.T) {
-	for _, c := range []struct {
-		path string
-		restic.Node
-		expect string
-	}{
-		// Mode is omitted when zero.
-		// Permissions, by convention is "-" per mode bit
-		{
-			path: "/bar/baz",
-			Node: restic.Node{
-				Name: "baz",
-				Type: "file",
-				Size: 12345,
-				UID:  10000000,
-				GID:  20000000,
-
-				User:  "nobody",
-				Group: "nobodies",
-				Links: 1,
-			},
-			expect: `{"name":"baz","type":"file","path":"/bar/baz","uid":10000000,"gid":20000000,"size":12345,"permissions":"----------","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`,
+type lsTestNode struct {
+	path string
+	restic.Node
+}
+
+var lsTestNodes = []lsTestNode{
+	// Mode is omitted when zero.
+	// Permissions, by convention is "-" per mode bit
+	{
+		path: "/bar/baz",
+		Node: restic.Node{
+			Name: "baz",
+			Type: "file",
+			Size: 12345,
+			UID:  10000000,
+			GID:  20000000,
+
+			User:  "nobody",
+			Group: "nobodies",
+			Links: 1,
+		},
+	},
+
+	// Even empty files get an explicit size.
+	{
+		path: "/foo/empty",
+		Node: restic.Node{
+			Name: "empty",
+			Type: "file",
+			Size: 0,
+			UID:  1001,
+			GID:  1001,
+
+			User:  "not printed",
+			Group: "not printed",
+			Links: 0xF00,
 		},
+	},
 
-		// Even empty files get an explicit size.
-		{
-			path: "/foo/empty",
-			Node: restic.Node{
-				Name: "empty",
-				Type: "file",
-				Size: 0,
-				UID:  1001,
-				GID:  1001,
-
-				User:  "not printed",
-				Group: "not printed",
-				Links: 0xF00,
-			},
-			expect: `{"name":"empty","type":"file","path":"/foo/empty","uid":1001,"gid":1001,"size":0,"permissions":"----------","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`,
+	// Non-regular files do not get a size.
+	// Mode is printed in decimal, including the type bits.
+	{
+		path: "/foo/link",
+		Node: restic.Node{
+			Name:       "link",
+			Type:       "symlink",
+			Mode:       os.ModeSymlink | 0777,
+			LinkTarget: "not printed",
 		},
+	},
 
-		// Non-regular files do not get a size.
-		// Mode is printed in decimal, including the type bits.
-		{
-			path: "/foo/link",
-			Node: restic.Node{
-				Name:       "link",
-				Type:       "symlink",
-				Mode:       os.ModeSymlink | 0777,
-				LinkTarget: "not printed",
-			},
-			expect: `{"name":"link","type":"symlink","path":"/foo/link","uid":0,"gid":0,"mode":134218239,"permissions":"Lrwxrwxrwx","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`,
+	{
+		path: "/some/directory",
+		Node: restic.Node{
+			Name:       "directory",
+			Type:       "dir",
+			Mode:       os.ModeDir | 0755,
+			ModTime:    time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC),
+			AccessTime: time.Date(2021, 2, 3, 4, 5, 6, 7, time.UTC),
+			ChangeTime: time.Date(2022, 3, 4, 5, 6, 7, 8, time.UTC),
 		},
+	},
 
-		{
-			path: "/some/directory",
-			Node: restic.Node{
-				Name:       "directory",
-				Type:       "dir",
-				Mode:       os.ModeDir | 0755,
-				ModTime:    time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC),
-				AccessTime: time.Date(2021, 2, 3, 4, 5, 6, 7, time.UTC),
-				ChangeTime: time.Date(2022, 3, 4, 5, 6, 7, 8, time.UTC),
-			},
-			expect: `{"name":"directory","type":"dir","path":"/some/directory","uid":0,"gid":0,"mode":2147484141,"permissions":"drwxr-xr-x","mtime":"2020-01-02T03:04:05Z","atime":"2021-02-03T04:05:06.000000007Z","ctime":"2022-03-04T05:06:07.000000008Z","struct_type":"node"}`,
+	// Test encoding of setuid/setgid/sticky bit
+	{
+		path: "/some/sticky",
+		Node: restic.Node{
+			Name: "sticky",
+			Type: "dir",
+			Mode: os.ModeDir | 0755 | os.ModeSetuid | os.ModeSetgid | os.ModeSticky,
 		},
+	},
+}
+
+func TestLsNodeJSON(t *testing.T) {
+	for i, expect := range []string{
+		`{"name":"baz","type":"file","path":"/bar/baz","uid":10000000,"gid":20000000,"size":12345,"permissions":"----------","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`,
+		`{"name":"empty","type":"file","path":"/foo/empty","uid":1001,"gid":1001,"size":0,"permissions":"----------","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`,
+		`{"name":"link","type":"symlink","path":"/foo/link","uid":0,"gid":0,"mode":134218239,"permissions":"Lrwxrwxrwx","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`,
+		`{"name":"directory","type":"dir","path":"/some/directory","uid":0,"gid":0,"mode":2147484141,"permissions":"drwxr-xr-x","mtime":"2020-01-02T03:04:05Z","atime":"2021-02-03T04:05:06.000000007Z","ctime":"2022-03-04T05:06:07.000000008Z","struct_type":"node"}`,
+		`{"name":"sticky","type":"dir","path":"/some/sticky","uid":0,"gid":0,"mode":2161115629,"permissions":"dugtrwxr-xr-x","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`,
 	} {
+		c := lsTestNodes[i]
 		buf := new(bytes.Buffer)
 		enc := json.NewEncoder(buf)
 		err := lsNodeJSON(enc, c.path, &c.Node)
 		rtest.OK(t, err)
-		rtest.Equals(t, c.expect+"\n", buf.String())
+		rtest.Equals(t, expect+"\n", buf.String())
 
 		// Sanity check: output must be valid JSON.
 		var v interface{}
@@ -90,3 +106,54 @@ func TestLsNodeJSON(t *testing.T) {
 		rtest.OK(t, err)
 	}
 }
+
+func TestLsNcduNode(t *testing.T) {
+	for i, expect := range []string{
+		`{"name":"baz","asize":12345,"dsize":12345,"dev":0,"ino":0,"nlink":1,"notreg":false,"uid":10000000,"gid":20000000,"mode":0,"mtime":-62135596800}`,
+		`{"name":"empty","asize":0,"dsize":0,"dev":0,"ino":0,"nlink":3840,"notreg":false,"uid":1001,"gid":1001,"mode":0,"mtime":-62135596800}`,
+		`{"name":"link","asize":0,"dsize":0,"dev":0,"ino":0,"nlink":0,"notreg":true,"uid":0,"gid":0,"mode":511,"mtime":-62135596800}`,
+		`{"name":"directory","asize":0,"dsize":0,"dev":0,"ino":0,"nlink":0,"notreg":false,"uid":0,"gid":0,"mode":493,"mtime":1577934245}`,
+		`{"name":"sticky","asize":0,"dsize":0,"dev":0,"ino":0,"nlink":0,"notreg":false,"uid":0,"gid":0,"mode":4077,"mtime":-62135596800}`,
+	} {
+		c := lsTestNodes[i]
+		out, err := lsNcduNode(c.path, &c.Node)
+		rtest.OK(t, err)
+		rtest.Equals(t, expect, string(out))
+
+		// Sanity check: output must be valid JSON.
+		var v interface{}
+		err = json.Unmarshal(out, &v)
+		rtest.OK(t, err)
+	}
+}
+
+func TestLsNcdu(t *testing.T) {
+	var buf bytes.Buffer
+	printer := &ncduLsPrinter{
+		out: &buf,
+	}
+
+	printer.Snapshot(&restic.Snapshot{
+		Hostname: "host",
+		Paths:    []string{"/example"},
+	})
+	printer.Node("/directory", &restic.Node{
+		Type: "dir",
+		Name: "directory",
+	})
+	printer.Node("/directory/data", &restic.Node{
+		Type: "file",
+		Name: "data",
+		Size: 42,
+	})
+	printer.LeaveDir("/directory")
+	printer.Close()
+
+	rtest.Equals(t, `[1, 2, {"time":"0001-01-01T00:00:00Z","tree":null,"paths":["/example"],"hostname":"host"},
+  [
+    {"name":"directory","asize":0,"dsize":0,"dev":0,"ino":0,"nlink":0,"notreg":false,"uid":0,"gid":0,"mode":0,"mtime":-62135596800},
+    {"name":"data","asize":42,"dsize":42,"dev":0,"ino":0,"nlink":0,"notreg":false,"uid":0,"gid":0,"mode":0,"mtime":-62135596800}
+  ]
+]
+`, buf.String())
+}
diff --git a/internal/archiver/testing.go b/internal/archiver/testing.go
index 111c1e68c..c7482d160 100644
--- a/internal/archiver/testing.go
+++ b/internal/archiver/testing.go
@@ -209,7 +209,7 @@ func TestEnsureFiles(t testing.TB, target string, dir TestDir) {
 }
 
 // TestEnsureFileContent checks if the file in the repo is the same as file.
-func TestEnsureFileContent(ctx context.Context, t testing.TB, repo restic.BlobLoader, filename string, node *restic.Node, file TestFile) {
+func TestEnsureFileContent(ctx context.Context, t testing.TB, repo restic.Repository, filename string, node *restic.Node, file TestFile) {
 	if int(node.Size) != len(file.Content) {
 		t.Fatalf("%v: wrong node size: want %d, got %d", filename, node.Size, len(file.Content))
 		return
@@ -237,7 +237,7 @@ func TestEnsureFileContent(ctx context.Context, t testing.TB, repo restic.BlobLo
 
 // TestEnsureTree checks that the tree ID in the repo matches dir. On Windows,
 // Symlinks are ignored.
-func TestEnsureTree(ctx context.Context, t testing.TB, prefix string, repo restic.BlobLoader, treeID restic.ID, dir TestDir) {
+func TestEnsureTree(ctx context.Context, t testing.TB, prefix string, repo restic.Repository, treeID restic.ID, dir TestDir) {
 	t.Helper()
 
 	tree, err := restic.LoadTree(ctx, repo, treeID)
diff --git a/internal/fuse/fuse_test.go b/internal/fuse/fuse_test.go
index 1053d49a4..0a121b986 100644
--- a/internal/fuse/fuse_test.go
+++ b/internal/fuse/fuse_test.go
@@ -37,7 +37,7 @@ func testRead(t testing.TB, f fs.Handle, offset, length int, data []byte) {
 	rtest.OK(t, fr.Read(ctx, req, resp))
 }
 
-func firstSnapshotID(t testing.TB, repo restic.Lister) (first restic.ID) {
+func firstSnapshotID(t testing.TB, repo restic.Repository) (first restic.ID) {
 	err := repo.List(context.TODO(), restic.SnapshotFile, func(id restic.ID, size int64) error {
 		if first.IsNull() {
 			first = id
@@ -52,14 +52,14 @@ func firstSnapshotID(t testing.TB, repo restic.Lister) (first restic.ID) {
 	return first
 }
 
-func loadFirstSnapshot(t testing.TB, repo restic.ListerLoaderUnpacked) *restic.Snapshot {
+func loadFirstSnapshot(t testing.TB, repo restic.Repository) *restic.Snapshot {
 	id := firstSnapshotID(t, repo)
 	sn, err := restic.LoadSnapshot(context.TODO(), repo, id)
 	rtest.OK(t, err)
 	return sn
 }
 
-func loadTree(t testing.TB, repo restic.Loader, id restic.ID) *restic.Tree {
+func loadTree(t testing.TB, repo restic.Repository, id restic.ID) *restic.Tree {
 	tree, err := restic.LoadTree(context.TODO(), repo, id)
 	rtest.OK(t, err)
 	return tree
diff --git a/internal/repository/repack_test.go b/internal/repository/repack_test.go
index 63845d5b1..3de077a7d 100644
--- a/internal/repository/repack_test.go
+++ b/internal/repository/repack_test.go
@@ -120,7 +120,7 @@ func selectBlobs(t *testing.T, repo restic.Repository, p float32) (list1, list2
 	return list1, list2
 }
 
-func listPacks(t *testing.T, repo restic.Lister) restic.IDSet {
+func listPacks(t *testing.T, repo restic.Repository) restic.IDSet {
 	list := restic.NewIDSet()
 	err := repo.List(context.TODO(), restic.PackFile, func(id restic.ID, size int64) error {
 		list.Insert(id)
diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go
index 0fa8e4d4a..886486136 100644
--- a/internal/repository/repository_test.go
+++ b/internal/repository/repository_test.go
@@ -229,7 +229,7 @@ func TestRepositoryLoadIndex(t *testing.T) {
 }
 
 // loadIndex loads the index id from backend and returns it.
-func loadIndex(ctx context.Context, repo restic.LoaderUnpacked, id restic.ID) (*index.Index, error) {
+func loadIndex(ctx context.Context, repo restic.Repository, id restic.ID) (*index.Index, error) {
 	buf, err := repo.LoadUnpacked(ctx, restic.IndexFile, id)
 	if err != nil {
 		return nil, err
diff --git a/internal/restic/lock_test.go b/internal/restic/lock_test.go
index 13b66a432..faf3f3593 100644
--- a/internal/restic/lock_test.go
+++ b/internal/restic/lock_test.go
@@ -120,7 +120,7 @@ func TestExclusiveLockOnLockedRepo(t *testing.T) {
 	rtest.OK(t, elock.Unlock())
 }
 
-func createFakeLock(repo restic.SaverUnpacked, t time.Time, pid int) (restic.ID, error) {
+func createFakeLock(repo restic.Repository, t time.Time, pid int) (restic.ID, error) {
 	hostname, err := os.Hostname()
 	if err != nil {
 		return restic.ID{}, err
@@ -254,7 +254,7 @@ func TestRemoveAllLocks(t *testing.T) {
 		3, processed)
 }
 
-func checkSingleLock(t *testing.T, repo restic.Lister) restic.ID {
+func checkSingleLock(t *testing.T, repo restic.Repository) restic.ID {
 	t.Helper()
 	var lockID *restic.ID
 	err := repo.List(context.TODO(), restic.LockFile, func(id restic.ID, size int64) error {
diff --git a/internal/restorer/restorer_test.go b/internal/restorer/restorer_test.go
index c33214bc3..d0e7dad6f 100644
--- a/internal/restorer/restorer_test.go
+++ b/internal/restorer/restorer_test.go
@@ -40,7 +40,7 @@ type Dir struct {
 	ModTime time.Time
 }
 
-func saveFile(t testing.TB, repo restic.BlobSaver, node File) restic.ID {
+func saveFile(t testing.TB, repo restic.Repository, node File) restic.ID {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
@@ -52,7 +52,7 @@ func saveFile(t testing.TB, repo restic.BlobSaver, node File) restic.ID {
 	return id
 }
 
-func saveDir(t testing.TB, repo restic.BlobSaver, nodes map[string]Node, inode uint64) restic.ID {
+func saveDir(t testing.TB, repo restic.Repository, nodes map[string]Node, inode uint64) restic.ID {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
diff --git a/internal/walker/walker_test.go b/internal/walker/walker_test.go
index 786570e02..0f0009107 100644
--- a/internal/walker/walker_test.go
+++ b/internal/walker/walker_test.go
@@ -93,12 +93,12 @@ func (t TreeMap) Connections() uint {
 
 // checkFunc returns a function suitable for walking the tree to check
 // something, and a function which will check the final result.
-type checkFunc func(t testing.TB) (walker WalkFunc, final func(testing.TB))
+type checkFunc func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB))
 
 // checkItemOrder ensures that the order of the 'path' arguments is the one passed in as 'want'.
 func checkItemOrder(want []string) checkFunc {
 	pos := 0
-	return func(t testing.TB) (walker WalkFunc, final func(testing.TB)) {
+	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB)) {
 		walker = func(treeID restic.ID, path string, node *restic.Node, err error) error {
 			if err != nil {
 				t.Errorf("error walking %v: %v", path, err)
@@ -117,20 +117,24 @@ func checkItemOrder(want []string) checkFunc {
 			return nil
 		}
 
+		leaveDir = func(path string) {
+			_ = walker(restic.ID{}, "leave: "+path, nil, nil)
+		}
+
 		final = func(t testing.TB) {
 			if pos != len(want) {
 				t.Errorf("not enough items returned, want %d, got %d", len(want), pos)
 			}
 		}
 
-		return walker, final
+		return walker, leaveDir, final
 	}
 }
 
 // checkParentTreeOrder ensures that the order of the 'parentID' arguments is the one passed in as 'want'.
 func checkParentTreeOrder(want []string) checkFunc {
 	pos := 0
-	return func(t testing.TB) (walker WalkFunc, final func(testing.TB)) {
+	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB)) {
 		walker = func(treeID restic.ID, path string, node *restic.Node, err error) error {
 			if err != nil {
 				t.Errorf("error walking %v: %v", path, err)
@@ -155,7 +159,7 @@ func checkParentTreeOrder(want []string) checkFunc {
 			}
 		}
 
-		return walker, final
+		return walker, nil, final
 	}
 }
 
@@ -164,7 +168,7 @@ func checkParentTreeOrder(want []string) checkFunc {
 func checkSkipFor(skipFor map[string]struct{}, wantPaths []string) checkFunc {
 	var pos int
 
-	return func(t testing.TB) (walker WalkFunc, final func(testing.TB)) {
+	return func(t testing.TB) (walker WalkFunc, leaveDir func(path string), final func(testing.TB)) {
 		walker = func(treeID restic.ID, path string, node *restic.Node, err error) error {
 			if err != nil {
 				t.Errorf("error walking %v: %v", path, err)
@@ -188,13 +192,17 @@ func checkSkipFor(skipFor map[string]struct{}, wantPaths []string) checkFunc {
 			return nil
 		}
 
+		leaveDir = func(path string) {
+			_ = walker(restic.ID{}, "leave: "+path, nil, nil)
+		}
+
 		final = func(t testing.TB) {
 			if pos != len(wantPaths) {
 				t.Errorf("wrong number of paths returned, want %d, got %d", len(wantPaths), pos)
 			}
 		}
 
-		return walker, final
+		return walker, leaveDir, final
 	}
 }
 
@@ -216,6 +224,8 @@ func TestWalker(t *testing.T) {
 					"/foo",
 					"/subdir",
 					"/subdir/subfile",
+					"leave: /subdir",
+					"leave: /",
 				}),
 				checkParentTreeOrder([]string{
 					"a760536a8fd64dd63f8dd95d85d788d71fd1bee6828619350daf6959dcb499a0", // tree /
@@ -230,6 +240,7 @@ func TestWalker(t *testing.T) {
 						"/",
 						"/foo",
 						"/subdir",
+						"leave: /",
 					},
 				),
 				checkSkipFor(
@@ -260,10 +271,14 @@ func TestWalker(t *testing.T) {
 					"/foo",
 					"/subdir1",
 					"/subdir1/subfile1",
+					"leave: /subdir1",
 					"/subdir2",
 					"/subdir2/subfile2",
 					"/subdir2/subsubdir2",
 					"/subdir2/subsubdir2/subsubfile3",
+					"leave: /subdir2/subsubdir2",
+					"leave: /subdir2",
+					"leave: /",
 				}),
 				checkParentTreeOrder([]string{
 					"7a0e59b986cc83167d9fbeeefc54e4629770124c5825d391f7ee0598667fcdf1", // tree /
@@ -286,6 +301,9 @@ func TestWalker(t *testing.T) {
 						"/subdir2/subfile2",
 						"/subdir2/subsubdir2",
 						"/subdir2/subsubdir2/subsubfile3",
+						"leave: /subdir2/subsubdir2",
+						"leave: /subdir2",
+						"leave: /",
 					},
 				),
 				checkSkipFor(
@@ -299,6 +317,8 @@ func TestWalker(t *testing.T) {
 						"/subdir2",
 						"/subdir2/subfile2",
 						"/subdir2/subsubdir2",
+						"leave: /subdir2",
+						"leave: /",
 					},
 				),
 				checkSkipFor(
@@ -307,6 +327,7 @@ func TestWalker(t *testing.T) {
 					}, []string{
 						"/",
 						"/foo",
+						"leave: /",
 					},
 				),
 			},
@@ -339,15 +360,19 @@ func TestWalker(t *testing.T) {
 					"/subdir1/subfile1",
 					"/subdir1/subfile2",
 					"/subdir1/subfile3",
+					"leave: /subdir1",
 					"/subdir2",
 					"/subdir2/subfile1",
 					"/subdir2/subfile2",
 					"/subdir2/subfile3",
+					"leave: /subdir2",
 					"/subdir3",
 					"/subdir3/subfile1",
 					"/subdir3/subfile2",
 					"/subdir3/subfile3",
+					"leave: /subdir3",
 					"/zzz other",
+					"leave: /",
 				}),
 				checkParentTreeOrder([]string{
 					"c2efeff7f217a4dfa12a16e8bb3cefedd37c00873605c29e5271c6061030672f", // tree /
@@ -385,13 +410,20 @@ func TestWalker(t *testing.T) {
 				checkItemOrder([]string{
 					"/",
 					"/subdir1",
+					"leave: /subdir1",
 					"/subdir2",
+					"leave: /subdir2",
 					"/subdir3",
 					"/subdir3/file",
+					"leave: /subdir3",
 					"/subdir4",
 					"/subdir4/file",
+					"leave: /subdir4",
 					"/subdir5",
+					"leave: /subdir5",
 					"/subdir6",
+					"leave: /subdir6",
+					"leave: /",
 				}),
 			},
 		},
@@ -405,8 +437,11 @@ func TestWalker(t *testing.T) {
 					ctx, cancel := context.WithCancel(context.TODO())
 					defer cancel()
 
-					fn, last := check(t)
-					err := Walk(ctx, repo, root, fn)
+					fn, leaveDir, last := check(t)
+					err := Walk(ctx, repo, root, WalkVisitor{
+						ProcessNode: fn,
+						LeaveDir:    leaveDir,
+					})
 					if err != nil {
 						t.Error(err)
 					}