// SPDX-License-Identifier: GPL-3.0-or-later package apply import ( "context" "errors" "os" "path/filepath" "strings" "testing" "git.labunix.xyz/krino/internal/plan" "git.labunix.xyz/krino/internal/scan" ) // planned writes body to root/rel and returns the chain a plan would build // for it, identity included, with steps. func planned(t *testing.T, root, rel, body string, steps ...plan.Step) plan.Chain { t.Helper() p := filepath.Join(root, rel) if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } info, err := os.Lstat(p) if err != nil { t.Fatal(err) } return plan.Chain{File: scan.NewFile(p, rel, info), Steps: steps} } // TestChainRefusesSourceSwappedForSymlink: a file replaced by a symlink // between plan and apply - even to a file of the same size and modification // time - is not acted on, so a copy never reads through the link. func TestChainRefusesSourceSwappedForSymlink(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.txt") out := filepath.Join(root, "Out", "a.txt") c := planned(t, root, "a.txt", "12345", plan.Step{Kind: plan.Copy, Src: p, Dst: out}) target := filepath.Join(root, "secret.txt") if err := os.WriteFile(target, []byte("54321"), 0o600); err != nil { t.Fatal(err) } if err := os.Chtimes(target, c.File.ModTime, c.File.ModTime); err != nil { t.Fatal(err) } if err := os.Remove(p); err != nil { t.Fatal(err) } if err := os.Symlink(target, p); err != nil { t.Fatal(err) } res := Chain(c) if res[0].Status != "failed" || !strings.HasPrefix(res[0].Detail, "changed since plan") { t.Fatalf("step = %s %q, want failed: changed since plan", res[0].Status, res[0].Detail) } if _, err := os.Lstat(out); !os.IsNotExist(err) { t.Errorf("the copy was made through the symlink: %v", err) } } // TestChainRefusesSourceReplacedByAnotherFile: another file renamed into // the planned path, with the same size and modification time, has another // inode: it is not the file that was planned. func TestChainRefusesSourceReplacedByAnotherFile(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.txt") c := planned(t, root, "a.txt", "12345", plan.Step{Kind: plan.Move, Src: p, Dst: filepath.Join(root, "Out", "a.txt")}) other := filepath.Join(root, "other.txt") if err := os.WriteFile(other, []byte("54321"), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(other, c.File.ModTime, c.File.ModTime); err != nil { t.Fatal(err) } if err := os.Rename(other, p); err != nil { t.Fatal(err) } res := Chain(c) if res[0].Status != "failed" || !strings.HasPrefix(res[0].Detail, "changed since plan") { t.Fatalf("step = %s %q, want failed: changed since plan", res[0].Status, res[0].Detail) } if b, err := os.ReadFile(p); err != nil || string(b) != "54321" { t.Errorf("the replacement was moved: %q, %v", b, err) } } // TestChainFollowsItsOwnFile: the file's identity check does not stop a // chain that renames and then moves the planned file itself. func TestChainFollowsItsOwnFile(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.txt") b := filepath.Join(root, "b.txt") c := planned(t, root, "a.txt", "12345", plan.Step{Kind: plan.Rename, Src: p, Dst: b}, plan.Step{Kind: plan.Move, Src: b, Dst: filepath.Join(root, "Out", "b.txt")}, ) for i, r := range Chain(c) { if r.Status != "ok" { t.Errorf("step %d: %s %q", i+1, r.Status, r.Detail) } } } // TestChainStopsWhenAStepLandsElsewhere: a move that found its planned name // taken at apply time lands at a free name, and every later step - planned // against the name it did not get - is skipped rather than acting on // whatever is at the planned path (review M3). func TestChainStopsWhenAStepLandsElsewhere(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.pdf") plannedPath := filepath.Join(root, "W", "a.pdf") c := planned(t, root, "a.pdf", "planned-file", plan.Step{Kind: plan.Move, Src: p, Dst: plannedPath}, plan.Step{Kind: plan.DeletePermanent, Src: plannedPath}, ) if err := os.MkdirAll(filepath.Dir(plannedPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(plannedPath, []byte("OTHER--FILE!"), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(plannedPath, c.File.ModTime, c.File.ModTime); err != nil { t.Fatal(err) } res := Chain(c) if res[0].Status != "ok" || res[1].Status != "skipped" || !strings.Contains(res[1].Detail, "not the planned") { t.Fatalf("steps = %s %q, %s %q; want ok, then skipped naming the planned path", res[0].Status, res[0].Dst, res[1].Status, res[1].Detail) } if b, err := os.ReadFile(plannedPath); err != nil || string(b) != "OTHER--FILE!" { t.Errorf("the file at the planned path was acted on: %q %v", b, err) } } // TestChainLoggedReportsEachStepBeforeTheNext: done is called for each step // as soon as it has run - the file is at the first step's destination and // the second step has not happened yet - so a caller logging from done // never has a completed step missing from the log (review M9). func TestChainLoggedReportsEachStepBeforeTheNext(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.pdf") moved := filepath.Join(root, "W", "a.pdf") renamed := filepath.Join(root, "W", "b.pdf") c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: p, Dst: moved}, plan.Step{Kind: plan.Rename, Src: moved, Dst: renamed}, ) var calls []int res, err := ChainLogged(context.Background(), c, func(i int, sr StepResult) error { calls = append(calls, i) if i == 0 { if _, err := os.Lstat(moved); err != nil { t.Errorf("at done(0) the move has not happened: %v", err) } if _, err := os.Lstat(renamed); !os.IsNotExist(err) { t.Errorf("at done(0) the rename already happened") } } return nil }, nil) if err != nil || len(calls) != 2 || res[1].Status != "ok" { t.Fatalf("calls %v, err %v, results %+v", calls, err, res) } } // TestChainRefusesToDisplaceANonRegularTarget: the file overwrite was // planned to replace is re-checked before it is trashed; a directory now in // its place is left alone (review M4). func TestChainRefusesToDisplaceANonRegularTarget(t *testing.T) { root := t.TempDir() t.Setenv("HOME", root) t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share")) t.Setenv("XDG_STATE_HOME", "") t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("XDG_CACHE_HOME", "") p := filepath.Join(root, "a.pdf") target := filepath.Join(root, "W", "a.pdf") c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: p, Dst: target, Displaces: target}) if err := os.MkdirAll(filepath.Join(target, "inside"), 0o755); err != nil { t.Fatal(err) } res := Chain(c) if res[0].Status != "failed" { t.Fatalf("step = %s %q; want failed", res[0].Status, res[0].Detail) } if _, err := os.Stat(filepath.Join(target, "inside")); err != nil { t.Errorf("the directory was displaced: %v", err) } } // TestChainLoggedStopsBetweenStepsWhenInterrupted: once the context is // cancelled, the chain finishes the step it is on and skips the rest, so an // interrupt stops after the current step, not after the file's whole chain // (re-review pa F7). func TestChainLoggedStopsBetweenStepsWhenInterrupted(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.pdf") moved := filepath.Join(root, "W", "a.pdf") c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: p, Dst: moved}, plan.Step{Kind: plan.Rename, Src: moved, Dst: filepath.Join(root, "W", "b.pdf")}, ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() res, err := ChainLogged(ctx, c, func(i int, sr StepResult) error { if i == 0 { cancel() } return nil }, nil) if err != nil { t.Fatal(err) } if res[0].Status != "ok" || res[1].Status != "skipped" || res[1].Detail != "interrupted" { t.Errorf("steps = %s, %s %q; want ok, then skipped as interrupted", res[0].Status, res[1].Status, res[1].Detail) } if _, err := os.Lstat(moved); err != nil { t.Errorf("the finished step was undone or never ran: %v", err) } } // TestDisplaceIsReportedBeforeTheStepThatNeededIt: trashing the file in the // way is a destructive act of its own, and it is durable the moment // trash.Put returns. Reporting it only when the whole step finishes leaves // a window - the entire data transfer of a copy or a cross-device move - // in which the user's file is in the Trash with nothing recording that it // went there. Killed in that window, krino log said "nothing applied". func TestDisplaceIsReportedBeforeTheStepThatNeededIt(t *testing.T) { root := t.TempDir() // The Trash must be on the same filesystem as the file being trashed. t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share")) src := filepath.Join(root, "a.pdf") dst := filepath.Join(root, "W", "a.pdf") if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(dst, []byte("the file already there"), 0o644); err != nil { t.Fatal(err) } c := planned(t, root, "a.pdf", "incoming", plan.Step{Kind: plan.Copy, Src: src, Dst: dst, Displaces: dst}, ) var order []string res, err := ChainLogged(context.Background(), c, func(i int, sr StepResult) error { order = append(order, "step") return nil }, func(i int, step plan.Step, entry string) error { order = append(order, "displace") if entry == "" { t.Error("the displace was reported with no trash entry") } // The file is in the Trash already; the copy has not begun. if b, err := os.ReadFile(dst); err == nil && string(b) == "incoming" { t.Error("the displace was reported only after the copy had run") } return nil }) if err != nil { t.Fatal(err) } if len(order) != 2 || order[0] != "displace" || order[1] != "step" { t.Errorf("order = %v; want the displace reported first", order) } if res[0].Status != "ok" { t.Errorf("step = %+v", res[0]) } } // TestDisplaceThatCannotBeReportedFailsTheStep: if the displace cannot be // written to the log, the step must not go on to use the name - the user's // file is already in the Trash and nothing would record it. func TestDisplaceThatCannotBeReportedFailsTheStep(t *testing.T) { root := t.TempDir() t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share")) src := filepath.Join(root, "a.pdf") dst := filepath.Join(root, "W", "a.pdf") if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(dst, []byte("the file already there"), 0o644); err != nil { t.Fatal(err) } c := planned(t, root, "a.pdf", "incoming", plan.Step{Kind: plan.Copy, Src: src, Dst: dst, Displaces: dst}, ) res, err := ChainLogged(context.Background(), c, nil, func(i int, step plan.Step, entry string) error { return errors.New("log is closed") }) if err != nil { t.Fatal(err) } if res[0].Status != "failed" || !strings.Contains(res[0].Detail, "could not be logged") { t.Errorf("step = %+v; want a failure naming the unlogged displace", res[0]) } if res[0].DisplacedEntry == "" { t.Error("the failure does not carry the trash entry, so nothing can say where the file went") } if b, rerr := os.ReadFile(dst); rerr == nil && string(b) == "incoming" { t.Error("the copy ran even though the displace could not be logged") } } // TestSymlinkInsideTheSortedDirectoryStopsTheStep: placeholders are already // stopped from redirecting a step out of the directory a rule named. A // symlink is a name too: one planted in the sorted directory - by an // unpacked archive, say - named after a rule's destination sends the file // anywhere, while the plan the user approved shows only "Out/". func TestSymlinkInsideTheSortedDirectoryStopsTheStep(t *testing.T) { root := t.TempDir() outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(root, "Out")); err != nil { t.Skipf("symlinks unavailable: %v", err) } src := filepath.Join(root, "a.pdf") dst := filepath.Join(root, "Out", "a.pdf") c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: src, Dst: dst}) c.Root = root res, err := ChainLogged(context.Background(), c, nil, nil) if err != nil { t.Fatal(err) } if res[0].Status != "failed" || !strings.Contains(res[0].Detail, "symlink") { t.Errorf("step = %+v; want a failure naming the symlink", res[0]) } if _, err := os.Lstat(filepath.Join(outside, "a.pdf")); err == nil { t.Error("the file left the sorted directory through the symlink") } if _, err := os.Lstat(src); err != nil { t.Errorf("the file is no longer where it started: %v", err) } } // TestASymlinkedDestinationOutsideTheSortedDirectoryIsFine: a destination // the configuration itself names - "~/docs/work", where ~/docs is a symlink // to another disk - is the user's own arrangement, not something planted, // and must keep working. func TestASymlinkedDestinationOutsideTheSortedDirectoryIsFine(t *testing.T) { root := t.TempDir() elsewhere := t.TempDir() real := filepath.Join(elsewhere, "real") if err := os.MkdirAll(real, 0o755); err != nil { t.Fatal(err) } link := filepath.Join(elsewhere, "docs") if err := os.Symlink(real, link); err != nil { t.Skipf("symlinks unavailable: %v", err) } src := filepath.Join(root, "a.pdf") dst := filepath.Join(link, "a.pdf") c := planned(t, root, "a.pdf", "body", plan.Step{Kind: plan.Move, Src: src, Dst: dst}) c.Root = root res, err := ChainLogged(context.Background(), c, nil, nil) if err != nil { t.Fatal(err) } if res[0].Status != "ok" { t.Fatalf("step = %+v; want it to go through", res[0]) } if _, err := os.Lstat(filepath.Join(real, "a.pdf")); err != nil { t.Errorf("the file did not reach the configured destination: %v", err) } }