// 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) } } }