package state import ( "bufio" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" ) const testHash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" func TestExclusiveSession(t *testing.T) { dir := t.TempDir() s, err := Acquire(dir, "host-one") if err != nil { t.Fatal(err) } t.Cleanup(func() { s.Close() }) if second, err := Acquire(dir, "host-one"); second != nil || !errors.Is(err, ErrBusy) { t.Fatalf("lock bypass: %v", err) } other, err := Acquire(t.TempDir(), "host-two") if err != nil { t.Fatal(err) } other.Close() if err := s.Close(); err != nil { t.Fatal(err) } if _, _, err := s.Begin("op-one", testHash); !errors.Is(err, ErrClosed) { t.Fatal("used closed session") } next, err := Acquire(dir, "host-one") if err != nil { t.Fatal(err) } next.Close() } func TestInvalidHost(t *testing.T) { for _, host := range []string{"", "../host", "Host", strings.Repeat("a", 49)} { if s, err := Acquire(t.TempDir(), host); err == nil { s.Close() t.Fatal("accepted invalid host") } } } func TestStatePathsRejectSymlinks(t *testing.T) { for _, name := range []string{"host.lock", "state.json"} { t.Run(name, func(t *testing.T) { dir, outside := t.TempDir(), filepath.Join(t.TempDir(), "outside") if err := os.WriteFile(outside, []byte("protected"), 0600); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(dir, name)); err != nil { t.Skipf("symlink privilege unavailable: %v", err) } if s, err := Acquire(dir, "host-one"); err == nil { s.Close() t.Fatal("accepted symlink state") } content, err := os.ReadFile(outside) if err != nil || string(content) != "protected" { t.Fatal("modified outside file") } }) } } // The subprocess exits without Close: the OS must release its lock, but its // running operation must remain recorded. No mock can test this boundary. func TestProcessDeathPreservesRunningOperation(t *testing.T) { if os.Getenv("DEPLOYCTL_STATE_CHILD") == "1" { s, err := Acquire(os.Getenv("DEPLOYCTL_STATE_DIR"), "host-one") if err != nil { t.Fatal(err) } op, _, err := s.Begin("op-one", testHash) if err != nil { t.Fatal(err) } if _, err = s.Advance(op.ID, op.Revision, Running); err != nil { t.Fatal(err) } fmt.Println("READY") bufio.NewReader(os.Stdin).ReadByte() os.Exit(3) } dir := t.TempDir() cmd := exec.Command(os.Args[0], "-test.run=^TestProcessDeathPreservesRunningOperation$") cmd.Env = append(os.Environ(), "DEPLOYCTL_STATE_CHILD=1", "DEPLOYCTL_STATE_DIR="+dir) stdin, err := cmd.StdinPipe() if err != nil { t.Fatal(err) } defer stdin.Close() stdout, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) line, err := bufio.NewReader(stdout).ReadString('\n') if err != nil || strings.TrimSpace(line) != "READY" { t.Fatalf("child failed: %q %v", line, err) } if _, err := Acquire(dir, "host-one"); !errors.Is(err, ErrBusy) { t.Fatalf("cross-process lock bypass: %v", err) } if err := cmd.Process.Kill(); err != nil { t.Fatal(err) } cmd.Wait() s, err := Acquire(dir, "host-one") if err != nil { t.Fatal(err) } defer s.Close() op, created, err := s.Begin("op-one", testHash) if err != nil || created || op.Status != Running || op.Revision != 2 { t.Fatalf("lost interrupted task: %+v %v", op, err) } if _, _, err := s.Begin("op-two", testHash); !errors.Is(err, ErrUnresolved) { t.Fatal("allowed writes before reconciliation") } }