backup: prevent exclude of backup targets

Track backup targets explicitly specified by the user and prevent
excluding them. This for example ensures that `restic backup
--exclude-if-present .git /home/user/data` backs up the `data` folder
even if there is a `.git` folder in `/home/user`.

Note that this does not suffice for commands like `restic backup --exclude data /home/user/data`
as the exclude pattern will still match every single file within `data`.
This commit is contained in:
Michael Eischer
2026-05-10 20:23:14 +02:00
parent c221cd06ad
commit 02cf8e5f23
6 changed files with 125 additions and 70 deletions
+18 -9
View File
@@ -334,7 +334,7 @@ func (arch *Archiver) saveDir(ctx context.Context, snPath string, dir string, me
return futureNode{}, err
}
snItem := join(snPath, name)
fn, excluded, err := arch.save(ctx, snItem, pathname, oldNode)
fn, excluded, err := arch.save(ctx, snItem, pathname, oldNode, false)
// return error early if possible
if err != nil {
@@ -449,7 +449,10 @@ func (arch *Archiver) allBlobsPresent(previous *data.Node) bool {
// Errors and completion needs to be handled by the caller.
//
// snPath is the path within the current snapshot.
func (arch *Archiver) save(ctx context.Context, snPath, target string, previous *data.Node) (fn futureNode, excluded bool, err error) {
//
// explicit is true when this path was a backup target (tree leaf with Explicit
// set); Excludes (`SelectByName` and `Select`) are skipped for that path only.
func (arch *Archiver) save(ctx context.Context, snPath, target string, previous *data.Node, explicit bool) (fn futureNode, excluded bool, err error) {
start := time.Now()
debug.Log("%v target %q, previous %v", snPath, target, previous)
@@ -472,7 +475,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
return err
}
// exclude files by path before running Lstat to reduce number of lstat calls
if !arch.SelectByName(abstarget) {
if !explicit && !arch.SelectByName(abstarget) {
debug.Log("%v is excluded by path", target)
return futureNode{}, true, nil
}
@@ -500,7 +503,7 @@ func (arch *Archiver) save(ctx context.Context, snPath, target string, previous
// ignore if file disappeared since it was returned by readdir
return filterError(filterNotExist(err))
}
if !arch.Select(abstarget, fi, arch.FS) {
if !explicit && !arch.Select(abstarget, fi, arch.FS) {
debug.Log("%v is excluded", target)
return futureNode{}, true, nil
}
@@ -694,7 +697,7 @@ func (arch *Archiver) saveTree(ctx context.Context, snPath string, atree *tree,
if err != nil {
return futureNode{}, 0, err
}
fn, excluded, err := arch.save(ctx, pathname, subatree.Path, oldNode)
fn, excluded, err := arch.save(ctx, pathname, subatree.Path, oldNode, subatree.Explicit)
if err != nil {
err = arch.error(subatree.Path, err)
@@ -775,9 +778,12 @@ func (arch *Archiver) dirPathToNode(snPath, target string) (node *data.Node, err
// resolveRelativeTargets replaces targets that only contain relative
// directories ("." or "../../") with the contents of the directory. Each
// element of target is processed with fs.Clean().
func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) {
//
// Paths returned with Explicit true are those the user listed literally; paths
// inserted from directory expansion have Explicit false.
func resolveRelativeTargets(filesys fs.FS, targets []string) ([]BackupTarget, error) {
debug.Log("targets before resolving: %v", targets)
result := make([]string, 0, len(targets))
result := make([]BackupTarget, 0, len(targets))
for _, target := range targets {
if target != "" && filesys.VolumeName(target) == target {
// special case to allow users to also specify a volume name "C:" instead of a path "C:\"
@@ -787,7 +793,7 @@ func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) {
}
pc, _ := pathComponents(filesys, target, false)
if len(pc) > 0 {
result = append(result, target)
result = append(result, BackupTarget{Path: target, Explicit: true})
continue
}
@@ -799,7 +805,10 @@ func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) {
sort.Strings(entries)
for _, name := range entries {
result = append(result, filesys.Join(target, name))
result = append(result, BackupTarget{
Path: filesys.Join(target, name),
Explicit: false,
})
}
}
+15 -7
View File
@@ -223,7 +223,7 @@ func TestArchiverSave(t *testing.T) {
wg, ctx := errgroup.WithContext(ctx)
arch.runWorkers(ctx, wg, uploader)
node, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "file"), nil)
node, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "file"), nil, false)
if err != nil {
t.Fatal(err)
}
@@ -300,7 +300,7 @@ func TestArchiverSaveReaderFS(t *testing.T) {
wg, ctx := errgroup.WithContext(ctx)
arch.runWorkers(ctx, wg, uploader)
node, excluded, err := arch.save(ctx, "/", filename, nil)
node, excluded, err := arch.save(ctx, "/", filename, nil, false)
t.Logf("Save returned %v %v", node, err)
if err != nil {
t.Fatal(err)
@@ -1106,7 +1106,11 @@ func TestArchiverSaveTree(t *testing.T) {
wg, ctx := errgroup.WithContext(ctx)
arch.runWorkers(ctx, wg, uploader)
atree, err := newTree(testFS, test.targets)
bt, err := resolveRelativeTargets(testFS, test.targets)
if err != nil {
t.Fatal(err)
}
atree, err := newTree(testFS, bt)
if err != nil {
t.Fatal(err)
}
@@ -1485,7 +1489,11 @@ func TestResolveRelativeTargetsSpecial(t *testing.T) {
targets, err := resolveRelativeTargets(&fs.Local{}, test.targets)
rtest.OK(t, err)
rtest.Equals(t, test.expected, targets)
paths := make([]string, len(targets))
for i, tgt := range targets {
paths[i] = tgt.Path
}
rtest.Equals(t, test.expected, paths)
})
}
}
@@ -2456,7 +2464,7 @@ func TestRacyFileTypeSwap(t *testing.T) {
arch.runWorkers(ctx, wg, uploader)
// fs.Track will panic if the file was not closed
_, excluded, err := arch.save(ctx, "/", tempfile, nil)
_, excluded, err := arch.save(ctx, "/", tempfile, nil, false)
rtest.Assert(t, err != nil && strings.Contains(err.Error(), "changed type, refusing to archive"), "save() returned wrong error: %v", err)
tpe := "file"
if dirError {
@@ -2545,7 +2553,7 @@ func TestIrregularFile(t *testing.T) {
defer cancel()
arch := New(repo, fs.Track{FS: override}, Options{})
_, excluded, err := arch.save(ctx, "/", tempfile, nil)
_, excluded, err := arch.save(ctx, "/", tempfile, nil, false)
if err == nil {
t.Fatalf("Save() should have failed")
}
@@ -2595,7 +2603,7 @@ func TestDisappearedFile(t *testing.T) {
// the subsequent file.Stat() call. Thus test both cases.
for _, errorOnOpen := range []bool{false, true} {
arch := New(repo, fs.Track{FS: &missingFS{FS: &fs.Local{}, errorOnOpen: errorOnOpen}}, Options{})
_, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "testdir"), nil)
_, excluded, err := arch.save(ctx, "/", filepath.Join(tempdir, "testdir"), nil, false)
rtest.OK(t, err)
rtest.Assert(t, excluded, "testfile should have been excluded")
}
+6 -5
View File
@@ -44,7 +44,7 @@ func (s *Scanner) scanTree(ctx context.Context, stats ScanStats, tree tree) (Sca
return ScanStats{}, err
}
stats, err = s.scan(ctx, stats, abstarget)
stats, err = s.scan(ctx, stats, abstarget, tree.Explicit)
if err != nil {
return ScanStats{}, err
}
@@ -96,13 +96,14 @@ func (s *Scanner) Scan(ctx context.Context, targets []string) error {
return nil
}
func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string) (ScanStats, error) {
// explicit is true when this path was an explicit backup target (same meaning as tree.Explicit on a leaf).
func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string, explicit bool) (ScanStats, error) {
if ctx.Err() != nil {
return stats, nil
}
// exclude files by path before running stat to reduce number of lstat calls
if !s.SelectByName(target) {
if !explicit && !s.SelectByName(target) {
return stats, nil
}
@@ -113,7 +114,7 @@ func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string) (Sca
}
// run remaining select functions that require file information
if !s.Select(target, fi, s.FS) {
if !explicit && !s.Select(target, fi, s.FS) {
return stats, nil
}
@@ -129,7 +130,7 @@ func (s *Scanner) scan(ctx context.Context, stats ScanStats, target string) (Sca
sort.Strings(names)
for _, name := range names {
stats, err = s.scan(ctx, stats, s.FS.Join(target, name))
stats, err = s.scan(ctx, stats, s.FS.Join(target, name), false)
if err != nil {
return stats, err
}
+24 -11
View File
@@ -25,6 +25,7 @@ type tree struct {
Path string // where the files/dirs to be saved are found
FileInfoPath string // where the dir can be found that is not included itself, but its subdirs
Root string // parent directory of the tree
Explicit bool // Explicit is true when Path was passed as a backup target
}
// pathComponents returns all path components of p. If a virtual directory
@@ -94,8 +95,9 @@ func rootDirectory(fs fs.FS, target string) string {
return rel
}
// Add adds a new file or directory to the tree.
func (t *tree) Add(fs fs.FS, path string) error {
// Add adds a new file or directory to the tree. explicit marks a path the user
// named as a backup target (see BackupTarget.Explicit).
func (t *tree) Add(fs fs.FS, path string, explicit bool) error {
if path == "" {
panic("invalid path (empty string)")
}
@@ -138,13 +140,14 @@ func (t *tree) Add(fs fs.FS, path string) error {
// use the original root dir if this is a virtual directory (volume name on Windows)
subroot = root
}
err := tree.add(fs, path, subroot, pc[1:])
err := tree.add(fs, path, subroot, pc[1:], explicit)
if err != nil {
return err
}
tree.FileInfoPath = subroot
} else {
tree.Path = path
tree.Explicit = explicit
}
t.Nodes[name] = tree
@@ -152,7 +155,7 @@ func (t *tree) Add(fs fs.FS, path string) error {
}
// add adds a new target path into the tree.
func (t *tree) add(fs fs.FS, target, root string, pc []string) error {
func (t *tree) add(fs fs.FS, target, root string, pc []string, explicit bool) error {
if len(pc) == 0 {
return errors.Errorf("invalid path %q", target)
}
@@ -167,7 +170,7 @@ func (t *tree) add(fs fs.FS, target, root string, pc []string) error {
node, ok := t.Nodes[name]
if !ok {
t.Nodes[name] = tree{Path: target}
t.Nodes[name] = tree{Path: target, Explicit: explicit}
return nil
}
@@ -175,6 +178,7 @@ func (t *tree) add(fs fs.FS, target, root string, pc []string) error {
return errors.Errorf("path is already set for target %v", target)
}
node.Path = target
node.Explicit = explicit
t.Nodes[name] = node
return nil
}
@@ -187,7 +191,7 @@ func (t *tree) add(fs fs.FS, target, root string, pc []string) error {
subroot := fs.Join(root, name)
node.FileInfoPath = subroot
err := node.add(fs, target, subroot, pc[1:])
err := node.add(fs, target, subroot, pc[1:], explicit)
if err != nil {
return err
}
@@ -221,7 +225,7 @@ func (t tree) NodeNames() []string {
// formatTree returns a text representation of the tree t.
func formatTree(t tree, indent string) (s string) {
for name, node := range t.Nodes {
s += fmt.Sprintf("%v/%v, root %q, path %q, meta %q\n", indent, name, node.Root, node.Path, node.FileInfoPath)
s += fmt.Sprintf("%v/%v, root %q, path %q, meta %q, explicit %v\n", indent, name, node.Root, node.Path, node.FileInfoPath, node.Explicit)
s += formatTree(node, indent+" ")
}
return s
@@ -255,6 +259,7 @@ func unrollTree(f fs.FS, t *tree) error {
t.Nodes[entry] = tree{Path: f.Join(t.Path, entry)}
}
t.Path = ""
t.Explicit = false
}
for i, subtree := range t.Nodes {
@@ -269,13 +274,21 @@ func unrollTree(f fs.FS, t *tree) error {
return nil
}
// BackupTarget is a resolved backup path and whether the user passed this path
// literally. Paths inserted when expanding a target with no path components
// (for example ".") have Explicit false so include/exclude rules still apply.
type BackupTarget struct {
Path string
Explicit bool
}
// newTree creates a Tree from the target files/directories.
func newTree(fs fs.FS, targets []string) (*tree, error) {
func newTree(fs fs.FS, targets []BackupTarget) (*tree, error) {
debug.Log("targets: %v", targets)
tree := &tree{}
seen := make(map[string]struct{})
for _, target := range targets {
target = fs.Clean(target)
for _, tgt := range targets {
target := fs.Clean(tgt.Path)
// skip duplicate targets
if _, ok := seen[target]; ok {
@@ -283,7 +296,7 @@ func newTree(fs fs.FS, targets []string) (*tree, error) {
}
seen[target] = struct{}{}
err := tree.Add(fs, target)
err := tree.Add(fs, target, tgt.Explicit)
if err != nil {
return nil, err
}
+48 -38
View File
@@ -14,6 +14,14 @@ import (
// debug.Log requires Tree.String.
var _ fmt.Stringer = tree{}
func testBackupTargets(paths []string) []BackupTarget {
tgts := make([]BackupTarget, len(paths))
for i, p := range paths {
tgts[i] = BackupTarget{Path: p, Explicit: true}
}
return tgts
}
func TestPathComponents(t *testing.T) {
var tests = []struct {
p string
@@ -150,24 +158,24 @@ func TestTree(t *testing.T) {
{
targets: []string{"foo"},
want: tree{Nodes: map[string]tree{
"foo": {Path: "foo", Root: "."},
"foo": {Path: "foo", Root: ".", Explicit: true},
}},
},
{
targets: []string{"foo", "bar", "baz"},
want: tree{Nodes: map[string]tree{
"foo": {Path: "foo", Root: "."},
"bar": {Path: "bar", Root: "."},
"baz": {Path: "baz", Root: "."},
"foo": {Path: "foo", Root: ".", Explicit: true},
"bar": {Path: "bar", Root: ".", Explicit: true},
"baz": {Path: "baz", Root: ".", Explicit: true},
}},
},
{
targets: []string{"foo/user1", "foo/user2", "foo/other"},
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("foo/user1")},
"user2": {Path: filepath.FromSlash("foo/user2")},
"other": {Path: filepath.FromSlash("foo/other")},
"user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true},
"user2": {Path: filepath.FromSlash("foo/user2"), Explicit: true},
"other": {Path: filepath.FromSlash("foo/other"), Explicit: true},
}},
}},
},
@@ -176,8 +184,8 @@ func TestTree(t *testing.T) {
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("foo/work/user1")},
"user2": {Path: filepath.FromSlash("foo/work/user2")},
"user1": {Path: filepath.FromSlash("foo/work/user1"), Explicit: true},
"user2": {Path: filepath.FromSlash("foo/work/user2"), Explicit: true},
}},
}},
}},
@@ -186,25 +194,25 @@ func TestTree(t *testing.T) {
targets: []string{"foo/user1", "bar/user1", "foo/other"},
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("foo/user1")},
"other": {Path: filepath.FromSlash("foo/other")},
"user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true},
"other": {Path: filepath.FromSlash("foo/other"), Explicit: true},
}},
"bar": {Root: ".", FileInfoPath: "bar", Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("bar/user1")},
"user1": {Path: filepath.FromSlash("bar/user1"), Explicit: true},
}},
}},
},
{
targets: []string{"../work"},
want: tree{Nodes: map[string]tree{
"work": {Root: "..", Path: filepath.FromSlash("../work")},
"work": {Root: "..", Path: filepath.FromSlash("../work"), Explicit: true},
}},
},
{
targets: []string{"../work/other"},
want: tree{Nodes: map[string]tree{
"work": {Root: "..", FileInfoPath: filepath.FromSlash("../work"), Nodes: map[string]tree{
"other": {Path: filepath.FromSlash("../work/other")},
"other": {Path: filepath.FromSlash("../work/other"), Explicit: true},
}},
}},
},
@@ -212,11 +220,11 @@ func TestTree(t *testing.T) {
targets: []string{"foo/user1", "../work/other", "foo/user2"},
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("foo/user1")},
"user2": {Path: filepath.FromSlash("foo/user2")},
"user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true},
"user2": {Path: filepath.FromSlash("foo/user2"), Explicit: true},
}},
"work": {Root: "..", FileInfoPath: filepath.FromSlash("../work"), Nodes: map[string]tree{
"other": {Path: filepath.FromSlash("../work/other")},
"other": {Path: filepath.FromSlash("../work/other"), Explicit: true},
}},
}},
},
@@ -224,11 +232,11 @@ func TestTree(t *testing.T) {
targets: []string{"foo/user1", "../foo/other", "foo/user2"},
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("foo/user1")},
"user2": {Path: filepath.FromSlash("foo/user2")},
"user1": {Path: filepath.FromSlash("foo/user1"), Explicit: true},
"user2": {Path: filepath.FromSlash("foo/user2"), Explicit: true},
}},
"foo-1": {Root: "..", FileInfoPath: filepath.FromSlash("../foo"), Nodes: map[string]tree{
"other": {Path: filepath.FromSlash("../foo/other")},
"other": {Path: filepath.FromSlash("../foo/other"), Explicit: true},
}},
}},
},
@@ -245,8 +253,8 @@ func TestTree(t *testing.T) {
Root: ".",
FileInfoPath: "foo",
Nodes: map[string]tree{
"file": {Path: filepath.FromSlash("foo/file")},
"work": {Path: filepath.FromSlash("foo/work")},
"file": {Path: filepath.FromSlash("foo/file"), Explicit: false},
"work": {Path: filepath.FromSlash("foo/work"), Explicit: true},
},
},
}},
@@ -266,8 +274,8 @@ func TestTree(t *testing.T) {
Root: ".",
FileInfoPath: "foo",
Nodes: map[string]tree{
"file": {Path: filepath.FromSlash("foo/file")},
"work": {Path: filepath.FromSlash("foo/work")},
"file": {Path: filepath.FromSlash("foo/file"), Explicit: false},
"work": {Path: filepath.FromSlash("foo/work"), Explicit: true},
},
},
}},
@@ -287,8 +295,8 @@ func TestTree(t *testing.T) {
"work": {
FileInfoPath: filepath.FromSlash("foo/work"),
Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("foo/work/user1")},
"user2": {Path: filepath.FromSlash("foo/work/user2")},
"user1": {Path: filepath.FromSlash("foo/work/user1"), Explicit: false},
"user2": {Path: filepath.FromSlash("foo/work/user2"), Explicit: true},
},
},
}},
@@ -308,8 +316,8 @@ func TestTree(t *testing.T) {
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"work": {FileInfoPath: filepath.FromSlash("foo/work"),
Nodes: map[string]tree{
"user1": {Path: filepath.FromSlash("foo/work/user1")},
"user2": {Path: filepath.FromSlash("foo/work/user2")},
"user1": {Path: filepath.FromSlash("foo/work/user1"), Explicit: false},
"user2": {Path: filepath.FromSlash("foo/work/user2"), Explicit: true},
},
},
}},
@@ -334,16 +342,17 @@ func TestTree(t *testing.T) {
targets: []string{"foo/work/user2/data/secret", "foo"},
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"other": {Path: filepath.FromSlash("foo/other")},
"other": {Path: filepath.FromSlash("foo/other"), Explicit: false},
"work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{
"user2": {FileInfoPath: filepath.FromSlash("foo/work/user2"), Nodes: map[string]tree{
"data": {FileInfoPath: filepath.FromSlash("foo/work/user2/data"), Nodes: map[string]tree{
"secret": {
Path: filepath.FromSlash("foo/work/user2/data/secret"),
Path: filepath.FromSlash("foo/work/user2/data/secret"),
Explicit: true,
},
}},
}},
"user3": {Path: filepath.FromSlash("foo/work/user3")},
"user3": {Path: filepath.FromSlash("foo/work/user3"), Explicit: false},
}},
}},
}},
@@ -373,11 +382,12 @@ func TestTree(t *testing.T) {
"driveA": {FileInfoPath: filepath.FromSlash("mnt/driveA"), Nodes: map[string]tree{
"work": {FileInfoPath: filepath.FromSlash("mnt/driveA/work"), Nodes: map[string]tree{
"driveB": {
Path: filepath.FromSlash("mnt/driveA/work/driveB"),
Path: filepath.FromSlash("mnt/driveA/work/driveB"),
Explicit: true,
},
"test1": {Path: filepath.FromSlash("mnt/driveA/work/test1")},
"test1": {Path: filepath.FromSlash("mnt/driveA/work/test1"), Explicit: false},
}},
"test2": {Path: filepath.FromSlash("mnt/driveA/test2")},
"test2": {Path: filepath.FromSlash("mnt/driveA/test2"), Explicit: false},
}},
}},
}},
@@ -387,7 +397,7 @@ func TestTree(t *testing.T) {
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{
"user": {Path: filepath.FromSlash("foo/work/user")},
"user": {Path: filepath.FromSlash("foo/work/user"), Explicit: true},
}},
}},
}},
@@ -397,7 +407,7 @@ func TestTree(t *testing.T) {
want: tree{Nodes: map[string]tree{
"foo": {Root: ".", FileInfoPath: "foo", Nodes: map[string]tree{
"work": {FileInfoPath: filepath.FromSlash("foo/work"), Nodes: map[string]tree{
"user": {Path: filepath.FromSlash("foo/work/user")},
"user": {Path: filepath.FromSlash("foo/work/user"), Explicit: true},
}},
}},
}},
@@ -409,7 +419,7 @@ func TestTree(t *testing.T) {
"c": {Root: `c:\`, FileInfoPath: `c:\`, Nodes: map[string]tree{
"users": {FileInfoPath: `c:\users`, Nodes: map[string]tree{
"foobar": {FileInfoPath: `c:\users\foobar`, Nodes: map[string]tree{
"temp": {Path: `c:\users\foobar\temp`},
"temp": {Path: `c:\users\foobar\temp`, Explicit: true},
}},
}},
}},
@@ -445,7 +455,7 @@ func TestTree(t *testing.T) {
back := rtest.Chdir(t, tempdir)
defer back()
tree, err := newTree(fs.Local{}, test.targets)
tree, err := newTree(fs.Local{}, testBackupTargets(test.targets))
if test.mustError {
if err == nil {
t.Fatal("expected error, got nil")