// SPDX-License-Identifier: GPL-3.0-or-later package apply import ( "os" "path/filepath" "strings" "testing" "krino/internal/plan" "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(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 }) 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) } }