package moves import ( "os" "path/filepath" "strings" "testing" "time" ) func TestJournalDirLayout(t *testing.T) { got := JournalDir("/data", "app.example.com", "op1") want := filepath.Join("/data", ".moves", "app.example.com", "op1") if got != want { t.Fatalf("JournalDir = %q, want %q", got, want) } } func TestReadJournalAbsentIsEmpty(t *testing.T) { dir := t.TempDir() j, err := ReadJournal(dir, "h", "op") if err != nil { t.Fatalf("absent journal: %v", err) } if j == nil || j.Host != "h" || j.Op != "op" || j.Phase != "" || j.Attempt != 0 || len(j.Steps) != 0 { t.Fatalf("absent journal not empty: %+v", j) } if j.Done("cut") { t.Fatal("empty journal reports a step done") } // Reading must not create anything. if _, err := os.Stat(filepath.Join(dir, ".moves")); !os.IsNotExist(err) { t.Fatalf("ReadJournal created state: %v", err) } } func TestJournalRoundTripAndDone(t *testing.T) { dir := t.TempDir() j, err := ReadJournal(dir, "h", "op") if err != nil { t.Fatal(err) } j.Phase = "cut" j.Attempt = 2 if j.Done("seal") { t.Fatal("Done before Mark") } j.Mark("seal", "sha256:abc") j.Mark("flip", "") if !j.Done("seal") || !j.Done("flip") || j.Done("land") { t.Fatalf("Done does not reflect Mark: %+v", j.Steps) } if err := j.Commit(dir); err != nil { t.Fatal(err) } if j.UpdatedAt.IsZero() || time.Since(j.UpdatedAt) > time.Minute { t.Fatalf("UpdatedAt not stamped: %v", j.UpdatedAt) } back, err := ReadJournal(dir, "h", "op") if err != nil { t.Fatal(err) } if back.V != j.V || back.Host != "h" || back.Op != "op" || back.Phase != "cut" || back.Attempt != 2 { t.Fatalf("round trip: %+v", back) } if back.Steps["seal"] != "sha256:abc" || !back.Done("flip") || back.Done("land") { t.Fatalf("steps lost: %+v", back.Steps) } if !back.UpdatedAt.Equal(j.UpdatedAt) { t.Fatalf("UpdatedAt %v != %v", back.UpdatedAt, j.UpdatedAt) } } func TestJournalWriteLeavesOnlyTheJournal(t *testing.T) { dir := t.TempDir() j, _ := ReadJournal(dir, "h", "op") j.Mark("a", "1") for i := 0; i < 3; i++ { j.Attempt = i if err := j.Commit(dir); err != nil { t.Fatal(err) } } ents, err := os.ReadDir(JournalDir(dir, "h", "op")) if err != nil { t.Fatal(err) } if len(ents) != 1 || ents[0].Name() != "journal.json" { t.Fatalf("directory holds %v, want only journal.json", ents) } fi, err := os.Stat(JournalDir(dir, "h", "op")) if err != nil { t.Fatal(err) } if fi.Mode().Perm() != 0o700 { t.Fatalf("journal dir mode %v, want 0700", fi.Mode().Perm()) } } func TestJournalCorruptFailsClosed(t *testing.T) { cases := map[string]string{ "garbage": "{not json", "empty": "", "truncated": `{"v":1,"host":"h","op":"op","steps":{"a"`, "wrong host": `{"v":1,"host":"other","op":"op"}`, "wrong op": `{"v":1,"host":"h","op":"other"}`, "future version": `{"v":99,"host":"h","op":"op"}`, "zero version": `{"host":"h","op":"op"}`, } for name, body := range cases { t.Run(name, func(t *testing.T) { dir := t.TempDir() jd := JournalDir(dir, "h", "op") if err := os.MkdirAll(jd, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(jd, "journal.json"), []byte(body), 0o600); err != nil { t.Fatal(err) } j, err := ReadJournal(dir, "h", "op") if err == nil { t.Fatalf("corrupt journal read as %+v", j) } }) } } func TestJournalRejectsUnsafeNames(t *testing.T) { dir := t.TempDir() for _, c := range [][2]string{{"", "op"}, {"h", ""}, {"../x", "op"}, {"h", "a/b"}, {".", "op"}, {"h", ".."}} { if _, err := ReadJournal(dir, c[0], c[1]); err == nil { t.Errorf("ReadJournal(%q,%q) accepted", c[0], c[1]) } j := &Journal{V: 1, Host: c[0], Op: c[1]} if err := j.Commit(dir); err == nil { t.Errorf("Write(%q,%q) accepted", c[0], c[1]) } } if ents, _ := os.ReadDir(dir); len(ents) != 0 { t.Fatalf("rejected names left files: %v", ents) } } func TestJournalMarkOnZeroValue(t *testing.T) { var j Journal j.Mark("x", "y") if !j.Done("x") || j.Steps["x"] != "y" { t.Fatalf("%+v", j) } if strings.Contains(JournalDir("d", "h", "o"), "..") { t.Fatal("unexpected") } } func TestMissingDirsListsWhatMkdirAllWillCreate(t *testing.T) { dir := t.TempDir() deep := filepath.Join(dir, "a", "b", "c") got, err := missingDirs(deep) if err != nil { t.Fatal(err) } want := []string{deep, filepath.Join(dir, "a", "b"), filepath.Join(dir, "a")} if strings.Join(got, "|") != strings.Join(want, "|") { t.Fatalf("missingDirs = %v, want %v", got, want) } if err := os.MkdirAll(deep, 0o700); err != nil { t.Fatal(err) } if got, err = missingDirs(deep); err != nil || len(got) != 0 { t.Fatalf("missingDirs of an existing dir = %v, %v", got, err) } } // A first write creates .moves/// (every component new); a second // write to another op reuses .moves//. Both write and read back. func TestJournalWriteCreatesTheChainAndReadsBack(t *testing.T) { dir := filepath.Join(t.TempDir(), "data") // dataDir itself is new too for _, op := range []string{"op1", "op2"} { j, err := ReadJournal(dir, "h", op) if err != nil { t.Fatal(err) } j.Mark("a", op) if err := j.Commit(dir); err != nil { t.Fatalf("Write %s: %v", op, err) } back, err := ReadJournal(dir, "h", op) if err != nil || back.Steps["a"] != op { t.Fatalf("read back %s: %+v %v", op, back, err) } } } func TestJournalWriteUnwritableParentErrorsCleanly(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root ignores directory permissions") } dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".moves"), 0o700); err != nil { t.Fatal(err) } if err := os.Chmod(filepath.Join(dir, ".moves"), 0o500); err != nil { t.Fatal(err) } defer os.Chmod(filepath.Join(dir, ".moves"), 0o700) j := &Journal{Host: "h", Op: "op"} if err := j.Commit(dir); err == nil { t.Fatal("Write under an unwritable parent succeeded") } if _, err := os.Stat(JournalDir(dir, "h", "op")); err == nil { t.Fatal("a failed Write left the journal directory") } }