diff --git a/internal/state/types.go b/internal/state/types.go new file mode 100644 index 0000000..ce7390d --- /dev/null +++ b/internal/state/types.go @@ -0,0 +1,194 @@ +// Package state owns the SQLite-backed baseline store that records the last +// successfully deployed representation of every managed target. This file holds +// the data-transfer objects and enum validators only: no SQL, XDG, or file-lock +// type crosses the package boundary here. Provider and store seams live in +// later files; the sole interface declared here is the narrow Clock. +package state + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +// SourceStatus marks a baseline row as currently deployed or historically +// retired. A retired row keeps its baseline for diagnostics and safe +// reactivation. +type SourceStatus string + +const ( + // StatusActive marks a row whose target is currently managed. + StatusActive SourceStatus = "active" + // StatusRetired marks a row whose source is gone but whose baseline remains. + StatusRetired SourceStatus = "retired" +) + +// ParseSourceStatus converts a raw string into a SourceStatus, rejecting +// unknown values verbatim. +func ParseSourceStatus(value string) (SourceStatus, error) { + status := SourceStatus(value) + if !status.Valid() { + return "", fmt.Errorf("state: unknown source status %q", value) + } + return status, nil +} + +// Valid reports whether status is one of the supported constants. +func (s SourceStatus) Valid() bool { + switch s { + case StatusActive, StatusRetired: + return true + } + return false +} + +// AliasLayer names the platform stratum an alias belongs to. Aliases, unlike +// files, admit an "all" layer that applies on every runtime. +type AliasLayer string + +const ( + // LayerAll applies the alias on every supported platform. + LayerAll AliasLayer = "all" + // LayerDarwin applies the alias only on Darwin. + LayerDarwin AliasLayer = "darwin" + // LayerLinux applies the alias only on Linux. + LayerLinux AliasLayer = "linux" +) + +// ParseAliasLayer converts a raw string into an AliasLayer, rejecting unknown +// values verbatim. +func ParseAliasLayer(value string) (AliasLayer, error) { + layer := AliasLayer(value) + if !layer.Valid() { + return "", fmt.Errorf("state: unknown alias layer %q", value) + } + return layer, nil +} + +// Valid reports whether layer is one of the supported constants. +func (l AliasLayer) Valid() bool { + switch l { + case LayerAll, LayerDarwin, LayerLinux: + return true + } + return false +} + +// Repository is one registered (root, home) pair tracked by state. RootPath and +// HomePath are canonical absolute identity anchors; all other paths are +// relative. +type Repository struct { + ID int64 + RootPath string + HomePath string + IsDefault bool + CreatedAt time.Time + LastSeenAt time.Time +} + +// FileBaseline is one persisted file baseline row. Target and source paths are +// slash-normalized relative forms; the hashes are 32-byte BLAKE3 digests. +type FileBaseline struct { + RepositoryID int64 + TargetPath string + GroupName string + SourcePath string + SourceKind deployment.FileKind + Layer deployment.Layer + BaselineContentHash deployment.Digest + BaselineSourceHash deployment.Digest + ExecutableBits uint32 + Status SourceStatus + AppliedAt time.Time + RetiredAt *time.Time +} + +// AliasBaseline is one persisted alias baseline row. Alias and canonical target +// paths are slash-normalized relative forms. +type AliasBaseline struct { + RepositoryID int64 + AliasPath string + CanonicalTargetPath string + GroupName string + Layer AliasLayer + Status SourceStatus + AppliedAt time.Time + RetiredAt *time.Time +} + +// Clock reports the current time so callers can inject a deterministic clock. +// This is the only interface declared in this package and stays at one method +// so it remains a narrow value seam, not a provider contract. +type Clock interface { + Now() time.Time +} + +// SystemClock reads the wall clock. It is the default Clock injected when a +// caller does not supply one. +type SystemClock struct{} + +// Now returns the current local time. +func (SystemClock) Now() time.Time { return time.Now() } + +// IsSlashRelative reports whether path is a non-empty relative path expressed +// with forward slashes only: it must not be absolute and must not contain a +// backslash. State rows store target and source paths in this form so the +// database stays portable across platforms. +func IsSlashRelative(path string) bool { + if path == "" || filepath.IsAbs(path) { + return false + } + return !strings.ContainsRune(path, '\\') +} + +// CloneTimestamp returns a defensive copy of when. A nil timestamp stays nil so +// callers can distinguish an active row from a retired one without allocating. +func CloneTimestamp(when *time.Time) *time.Time { + if when == nil { + return nil + } + copied := *when + return &copied +} + +// CopyRepositories returns a defensive copy of rows so a caller cannot mutate +// the source slice through the returned reference. +func CopyRepositories(rows []Repository) []Repository { + if rows == nil { + return nil + } + out := make([]Repository, len(rows)) + copy(out, rows) + return out +} + +// CopyFileBaselines returns a defensive copy of rows, cloning the optional +// retirement timestamps so a caller cannot mutate state-owned pointers. +func CopyFileBaselines(rows []FileBaseline) []FileBaseline { + if rows == nil { + return nil + } + out := make([]FileBaseline, len(rows)) + copy(out, rows) + for index := range out { + out[index].RetiredAt = CloneTimestamp(out[index].RetiredAt) + } + return out +} + +// CopyAliasBaselines returns a defensive copy of rows, cloning the optional +// retirement timestamps so a caller cannot mutate state-owned pointers. +func CopyAliasBaselines(rows []AliasBaseline) []AliasBaseline { + if rows == nil { + return nil + } + out := make([]AliasBaseline, len(rows)) + copy(out, rows) + for index := range out { + out[index].RetiredAt = CloneTimestamp(out[index].RetiredAt) + } + return out +} diff --git a/internal/state/types_test.go b/internal/state/types_test.go new file mode 100644 index 0000000..0abcd7a --- /dev/null +++ b/internal/state/types_test.go @@ -0,0 +1,109 @@ +package state + +import ( + "reflect" + "testing" + "time" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestStateContract(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"enum validity", testEnumValidity}, + {"digest width", testDigestWidth}, + {"defensive copies", testDefensiveCopy}, + {"slash-relative path forms", testPathForms}, + {"clock is the only narrow seam", testClockSeam}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testEnumValidity(t *testing.T) { + if !StatusActive.Valid() || !StatusRetired.Valid() { + t.Fatal("known source statuses must be valid") + } + if _, err := ParseSourceStatus("active"); err != nil { + t.Fatalf("ParseSourceStatus active: %v", err) + } + if _, err := ParseSourceStatus("garbage"); err == nil { + t.Fatal("ParseSourceStatus accepted an unknown value") + } + if !LayerAll.Valid() || !LayerDarwin.Valid() || !LayerLinux.Valid() { + t.Fatal("known alias layers must be valid") + } + if _, err := ParseAliasLayer("all"); err != nil { + t.Fatalf("ParseAliasLayer all: %v", err) + } + if _, err := ParseAliasLayer("base"); err == nil { + t.Fatal("ParseAliasLayer accepted a file-only layer") + } +} + +func testDigestWidth(t *testing.T) { + var hash deployment.Digest + if len(hash) != 32 { + t.Fatalf("deployment.Digest width = %d, want 32", len(hash)) + } + if len(deployment.Digest{}) != 32 { + t.Fatal("zero Digest must still be 32 bytes wide") + } +} + +func testDefensiveCopy(t *testing.T) { + when := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + files := []FileBaseline{{TargetPath: "git/config", RetiredAt: &when}} + copiedFiles := CopyFileBaselines(files) + copiedFiles[0].TargetPath = "moved" + *copiedFiles[0].RetiredAt = time.Time{} + if files[0].TargetPath != "git/config" || !files[0].RetiredAt.Equal(when) { + t.Fatal("source file baseline mutated through its defensive copy") + } + aliases := []AliasBaseline{{AliasPath: "bin/x", RetiredAt: &when}} + copiedAliases := CopyAliasBaselines(aliases) + copiedAliases[0].AliasPath = "moved" + *copiedAliases[0].RetiredAt = time.Time{} + if aliases[0].AliasPath != "bin/x" || !aliases[0].RetiredAt.Equal(when) { + t.Fatal("source alias baseline mutated through its defensive copy") + } + repositories := []Repository{{RootPath: "/repo"}} + copiedRepos := CopyRepositories(repositories) + copiedRepos[0].RootPath = "/other" + if repositories[0].RootPath != "/repo" { + t.Fatal("source repository mutated through its defensive copy") + } +} + +func testPathForms(t *testing.T) { + good := []string{"git/config", "shell/bashrc", "group/file"} + for _, path := range good { + if !IsSlashRelative(path) { + t.Fatalf("IsSlashRelative(%q) = false, want true", path) + } + } + bad := []string{"", "/absolute", "back\\slash"} + for _, path := range bad { + if IsSlashRelative(path) { + t.Fatalf("IsSlashRelative(%q) = true, want false", path) + } + } +} + +func testClockSeam(t *testing.T) { + var clock Clock = SystemClock{} + if clock.Now().IsZero() { + t.Fatal("SystemClock.Now returned the zero time") + } + clockType := reflect.TypeOf((*Clock)(nil)).Elem() + if clockType.NumMethod() != 1 { + t.Fatalf("Clock declares %d methods, want 1", clockType.NumMethod()) + } + if clockType.Method(0).Name != "Now" { + t.Fatalf("Clock method = %s, want Now", clockType.Method(0).Name) + } +} -- 2.51.2 From c99aa222eecc6dafb232197a658edfe4d61b696a Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 14:53:40 -0400 Subject: [PATCH 2/5] feat: open state database --- internal/state/database.go | 244 ++++++++++++++++++++++++++++++++ internal/state/database_test.go | 235 ++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 internal/state/database.go create mode 100644 internal/state/database_test.go diff --git a/internal/state/database.go b/internal/state/database.go new file mode 100644 index 0000000..41572fb --- /dev/null +++ b/internal/state/database.go @@ -0,0 +1,244 @@ +package state + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + "github.com/adrg/xdg" + + "github.com/alyraffauf/cattery/internal/pathsafe" + + // The modernc.org/sqlite driver registers itself under the name "sqlite" + // so database/sql can open a pure-Go SQLite connection. + _ "modernc.org/sqlite" +) + +// Filesystem placement and required modes for the Cattery state directory +// (PLAN.md Section 8.1). The directory is private to the owning user; the +// database and lock files are read-write but never searchable by others. +const ( + catteryDirectoryName = "cattery" + stateDatabaseFileName = "state.db" + stateLockFileName = "cattery.lock" + stateDirectoryMode os.FileMode = 0o700 + stateFileMode os.FileMode = 0o600 +) + +// sqliteDriverName is the registration name modernc.org/sqlite uses. +const sqliteDriverName = "sqlite" + +// Database is an open SQLite state connection. Construction stores the resolved +// path only; Open performs every filesystem effect so a freshly constructed +// Database touches nothing on disk. +type Database struct { + path string + conn *sql.DB +} + +// NewDatabase constructs a handle bound to path. No filesystem access occurs +// until Open runs. +func NewDatabase(path string) *Database { + return &Database{path: path} +} + +// Path returns the resolved database path the handle is bound to. +func (database *Database) Path() string { + return database.path +} + +// Open prepares the private state directory and database file, opens SQLite with +// a single connection, and applies the Section 8.5 PRAGMAs. It performs no +// locking and no schema migration. +func (database *Database) Open() error { + if err := prepareStateDirectory(filepath.Dir(database.path)); err != nil { + return err + } + if err := preparePrivateFile(database.path); err != nil { + return err + } + conn, err := openConnection(database.path) + if err != nil { + return err + } + database.conn = conn + return nil +} + +// Close releases the underlying connection pool and is safe to call on a handle +// that was never opened or whose Open failed. +func (database *Database) Close() error { + if database.conn == nil { + return nil + } + err := database.conn.Close() + database.conn = nil + return err +} + +// ResolveDatabasePath resolves the canonical absolute path of the SQLite state +// database beneath $XDG_STATE_HOME/cattery/state.db. A relative XDG_STATE_HOME +// is rejected rather than resolved against the working directory. +func ResolveDatabasePath() (string, error) { + home, err := resolveStateHome("") + if err != nil { + return "", err + } + directory, err := resolveCatteryDirectory(home) + if err != nil { + return "", err + } + return filepath.Join(directory, stateDatabaseFileName), nil +} + +// resolveStateHome returns the validated state home. An explicit absolute path +// overrides the environment; an empty explicit reads XDG_STATE_HOME and rejects +// a relative value before falling back to the XDG default. +func resolveStateHome(explicit string) (string, error) { + if explicit == "" { + return resolveEnvStateHome() + } + if !filepath.IsAbs(explicit) { + return "", errRelativeStateHome(explicit) + } + return explicit, nil +} + +func resolveEnvStateHome() (string, error) { + raw := os.Getenv("XDG_STATE_HOME") + if raw != "" && !filepath.IsAbs(raw) { + return "", errRelativeStateHome(raw) + } + return xdg.StateHome, nil +} + +func errRelativeStateHome(value string) error { + return fmt.Errorf("state: state home %q is not absolute", value) +} + +// resolveCatteryDirectory resolves the cattery directory through its nearest +// existing canonical ancestor so a missing path is pinned before creation. +func resolveCatteryDirectory(home string) (string, error) { + return pathsafe.CanonicalRoot(filepath.Join(home, catteryDirectoryName)) +} + +// prepareStateDirectory creates the private directory when absent and rejects an +// existing entry that is not a real directory with the required mode. +func prepareStateDirectory(directory string) error { + info, err := os.Lstat(directory) + if err == nil { + return verifyStateDirectory(directory, info) + } + if !os.IsNotExist(err) { + return err + } + return createPrivateDirectory(directory) +} + +func verifyStateDirectory(directory string, info os.FileInfo) error { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errNotDirectory(directory, info.Mode()) + } + if info.Mode().Perm() != stateDirectoryMode { + return errWrongDirectoryMode(directory, info.Mode().Perm()) + } + return nil +} + +func createPrivateDirectory(directory string) error { + if err := os.MkdirAll(directory, stateDirectoryMode); err != nil { + return err + } + return os.Chmod(directory, stateDirectoryMode) +} + +// preparePrivateFile creates the file when absent and rejects an existing entry +// that is not a regular file with the required mode. Symlinks and special +// entries are refused so a state file can never be redirected. +func preparePrivateFile(path string) error { + info, err := os.Lstat(path) + if err == nil { + return verifyPrivateFile(path, info) + } + if !os.IsNotExist(err) { + return err + } + return createPrivateFile(path) +} + +func verifyPrivateFile(path string, info os.FileInfo) error { + if !info.Mode().IsRegular() { + return errNotRegular(path, info.Mode()) + } + if info.Mode().Perm() != stateFileMode { + return errWrongFileMode(path, info.Mode().Perm()) + } + return nil +} + +func createPrivateFile(path string) error { + handle, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, stateFileMode) + if err != nil { + return err + } + if err := handle.Close(); err != nil { + return err + } + return os.Chmod(path, stateFileMode) +} + +func openConnection(path string) (*sql.DB, error) { + conn, err := sql.Open(sqliteDriverName, path) + if err != nil { + return nil, err + } + conn.SetMaxOpenConns(1) + if err := applyPragmas(conn); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +func applyPragmas(conn *sql.DB) error { + for _, pragma := range sqlitePragmas() { + if _, err := conn.Exec(pragma); err != nil { + return fmt.Errorf("state: %s: %w", pragma, err) + } + } + return nil +} + +func sqlitePragmas() []string { + return []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = FULL", + } +} + +func errNotDirectory(directory string, mode os.FileMode) error { + return fmt.Errorf( + "state: %q is not a regular directory (mode %v); expected %v", + directory, mode, stateDirectoryMode) +} + +func errWrongDirectoryMode(directory string, mode os.FileMode) error { + return fmt.Errorf( + "state: %q has mode %v; run `chmod %o %q` to correct it", + directory, mode, stateDirectoryMode, directory) +} + +func errNotRegular(path string, mode os.FileMode) error { + return fmt.Errorf( + "state: %q is not a regular file (mode %v); remove the non-regular entry", + path, mode) +} + +func errWrongFileMode(path string, mode os.FileMode) error { + return fmt.Errorf( + "state: %q has mode %v; run `chmod %o %q` to correct it", + path, mode, stateFileMode, path) +} diff --git a/internal/state/database_test.go b/internal/state/database_test.go new file mode 100644 index 0000000..1a957ac --- /dev/null +++ b/internal/state/database_test.go @@ -0,0 +1,235 @@ +package state + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestDatabaseOpen(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"construction touches no filesystem", testConstructionTouchesNoFilesystem}, + {"creates private modes", testCreatesPrivateModes}, + {"restrictive umask keeps modes", testUmaskStaysRestrictive}, + {"pragmas applied", testPragmasApplied}, + {"rejects symlink database", testRejectsSymlinkDatabase}, + {"rejects non-regular database", testRejectsNonRegularDatabase}, + {"rejects wrong directory mode", testRejectsWrongDirectoryMode}, + {"rejects wrong file mode", testRejectsWrongFileMode}, + {"open failure leaves no connection", testOpenFailureLeavesNoConnection}, + {"rejects relative state home", testRejectsRelativeStateHome}, + {"resolves cattery database path", testResolvesCatteryDatabasePath}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testConstructionTouchesNoFilesystem(t *testing.T) { + path := tempDatabasePath(t) + database := NewDatabase(path) + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("NewDatabase created files: %v", err) + } + if database.Path() != path { + t.Fatalf("Path = %q, want %q", database.Path(), path) + } +} + +func testCreatesPrivateModes(t *testing.T) { + database := openTestDatabase(t) + defer database.Close() + directoryMode := modeOf(t, filepath.Dir(database.Path())) + if directoryMode != stateDirectoryMode { + t.Fatalf("directory mode %o, want %o", directoryMode, stateDirectoryMode) + } + if modeOf(t, database.Path()) != stateFileMode { + t.Fatalf("file mode %o, want %o", modeOf(t, database.Path()), stateFileMode) + } +} + +func testUmaskStaysRestrictive(t *testing.T) { + saved := syscall.Umask(0o077) + defer syscall.Umask(saved) + database := openTestDatabase(t) + defer database.Close() + directoryMode := modeOf(t, filepath.Dir(database.Path())) + if directoryMode != stateDirectoryMode { + t.Fatalf("directory mode %o under restrictive umask, want %o", directoryMode, stateDirectoryMode) + } + if modeOf(t, database.Path()) != stateFileMode { + t.Fatalf("file mode under restrictive umask, want %o", stateFileMode) + } +} + +func testPragmasApplied(t *testing.T) { + database := openTestDatabase(t) + defer database.Close() + expectations := []pragmaExpectation{ + {name: "foreign_keys", want: "1"}, + {name: "busy_timeout", want: "5000"}, + {name: "journal_mode", want: "wal"}, + {name: "synchronous", want: "2"}, + } + for _, expectation := range expectations { + assertPragma(t, database.conn, expectation) + } +} + +func testRejectsSymlinkDatabase(t *testing.T) { + target := prepareCatteryChild(t) + if err := os.Symlink("/dev/null", target); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if err := NewDatabase(target).Open(); err == nil { + t.Fatal("Open accepted a symlink database file") + } +} + +func testRejectsNonRegularDatabase(t *testing.T) { + target := prepareCatteryChild(t) + if err := os.Mkdir(target, stateFileMode); err != nil { + t.Fatal(err) + } + if err := NewDatabase(target).Open(); err == nil { + t.Fatal("Open accepted a non-regular database entry") + } +} + +func testRejectsWrongDirectoryMode(t *testing.T) { + directory := filepath.Join(t.TempDir(), catteryDirectoryName) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(directory, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(directory, stateDatabaseFileName) + if err := NewDatabase(target).Open(); err == nil { + t.Fatal("Open accepted a directory with the wrong mode") + } +} + +func testRejectsWrongFileMode(t *testing.T) { + target := prepareCatteryChild(t) + createFile(t, target, 0o644) + if err := NewDatabase(target).Open(); err == nil { + t.Fatal("Open accepted a database file with the wrong mode") + } +} + +func testOpenFailureLeavesNoConnection(t *testing.T) { + parent := filepath.Join(t.TempDir(), "blocker") + createFile(t, parent, 0o600) + target := filepath.Join(parent, catteryDirectoryName, stateDatabaseFileName) + database := NewDatabase(target) + if err := database.Open(); err == nil { + t.Fatal("Open succeeded against a path whose parent is a file") + } + if err := database.Close(); err != nil { + t.Fatalf("Close after failed Open: %v", err) + } +} + +func testRejectsRelativeStateHome(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "relative/path") + if _, err := ResolveDatabasePath(); err == nil { + t.Fatal("ResolveDatabasePath accepted a relative XDG_STATE_HOME") + } + if _, err := resolveStateHome("relative/path"); err == nil { + t.Fatal("resolveStateHome accepted a relative explicit home") + } + home := t.TempDir() + got, err := resolveStateHome(home) + if err != nil { + t.Fatalf("resolveStateHome absolute: %v", err) + } + if got != home { + t.Fatalf("resolveStateHome = %q, want %q", got, home) + } +} + +func testResolvesCatteryDatabasePath(t *testing.T) { + path, err := ResolveDatabasePath() + if err != nil { + t.Fatalf("ResolveDatabasePath: %v", err) + } + suffix := "/" + catteryDirectoryName + "/" + stateDatabaseFileName + if !strings.HasSuffix(path, suffix) { + t.Fatalf("ResolveDatabasePath = %q, want suffix %q", path, suffix) + } +} + +func tempDatabasePath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), catteryDirectoryName, stateDatabaseFileName) +} + +func openTestDatabase(t *testing.T) *Database { + t.Helper() + database := NewDatabase(tempDatabasePath(t)) + if err := database.Open(); err != nil { + t.Fatalf("Open: %v", err) + } + return database +} + +func prepareCatteryChild(t *testing.T) string { + t.Helper() + directory := filepath.Join(t.TempDir(), catteryDirectoryName) + if err := os.MkdirAll(directory, stateDirectoryMode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(directory, stateDirectoryMode); err != nil { + t.Fatal(err) + } + return filepath.Join(directory, stateDatabaseFileName) +} + +func createFile(t *testing.T, path string, mode os.FileMode) { + t.Helper() + handle, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, mode) + if err != nil { + t.Fatal(err) + } + if err := handle.Close(); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +} + +func modeOf(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + return info.Mode().Perm() +} + +type pragmaExpectation struct { + name string + want string +} + +func assertPragma(t *testing.T, conn *sql.DB, expectation pragmaExpectation) { + t.Helper() + var got any + query := "PRAGMA " + expectation.name + if err := conn.QueryRow(query).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", expectation.name, err) + } + rendered := fmt.Sprint(got) + if rendered != expectation.want { + t.Fatalf("PRAGMA %s = %q, want %q", expectation.name, rendered, expectation.want) + } +} -- 2.51.2 From b2e703c53f4c03a719ce8f239254e07a0e40a5b5 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 14:54:45 -0400 Subject: [PATCH 3/5] feat: lock state directory --- internal/state/database_test.go | 9 +-- internal/state/lock.go | 89 +++++++++++++++++++++ internal/state/lock_test.go | 135 ++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 internal/state/lock.go create mode 100644 internal/state/lock_test.go diff --git a/internal/state/database_test.go b/internal/state/database_test.go index 1a957ac..4c9562d 100644 --- a/internal/state/database_test.go +++ b/internal/state/database_test.go @@ -183,14 +183,7 @@ func openTestDatabase(t *testing.T) *Database { func prepareCatteryChild(t *testing.T) string { t.Helper() - directory := filepath.Join(t.TempDir(), catteryDirectoryName) - if err := os.MkdirAll(directory, stateDirectoryMode); err != nil { - t.Fatal(err) - } - if err := os.Chmod(directory, stateDirectoryMode); err != nil { - t.Fatal(err) - } - return filepath.Join(directory, stateDatabaseFileName) + return filepath.Join(prepareCatteryDirectory(t), stateDatabaseFileName) } func createFile(t *testing.T, path string, mode os.FileMode) { diff --git a/internal/state/lock.go b/internal/state/lock.go new file mode 100644 index 0000000..7eba854 --- /dev/null +++ b/internal/state/lock.go @@ -0,0 +1,89 @@ +package state + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/gofrs/flock" +) + +// Lock is an advisory process lock over the Cattery state directory. Only one +// Cattery process may hold it at a time so concurrent commands never race on +// state. Construction is side-effect-free; Acquire performs the filesystem +// work and immediate exclusive acquisition. +type Lock struct { + path string + flock *flock.Flock +} + +// NewLock constructs a handle bound to the lock file path. No filesystem access +// occurs until Acquire runs. +func NewLock(path string) *Lock { + return &Lock{path: path} +} + +// Path returns the resolved lock file path the handle is bound to. +func (lock *Lock) Path() string { + return lock.path +} + +// Acquire prepares the lock file with the required mode and acquires an +// exclusive advisory lock, failing immediately when another Cattery process +// holds it. After acquisition it writes the current PID to the lock file for +// diagnostics. +func (lock *Lock) Acquire() error { + if err := preparePrivateFile(lock.path); err != nil { + return err + } + lock.flock = flock.New(lock.path) + locked, err := lock.flock.TryLock() + if err != nil { + return fmt.Errorf("state: lock %q: %w", lock.path, err) + } + if !locked { + return errLockHeld(lock.path) + } + return writeProcessID(lock.path) +} + +// Release releases the advisory lock. It is idempotent: calling Release on a +// lock that was never acquired or already released is a no-op. +func (lock *Lock) Release() error { + if lock.flock == nil { + return nil + } + err := lock.flock.Unlock() + lock.flock = nil + return err +} + +// ResolveLockPath resolves the canonical absolute path of the advisory lock +// beneath $XDG_STATE_HOME/cattery/cattery.lock. A relative XDG_STATE_HOME is +// rejected rather than resolved against the working directory. +func ResolveLockPath() (string, error) { + home, err := resolveStateHome("") + if err != nil { + return "", err + } + directory, err := resolveCatteryDirectory(home) + if err != nil { + return "", err + } + return filepath.Join(directory, stateLockFileName), nil +} + +// writeProcessID writes the current PID to the lock file for diagnostics and +// restores the required file mode so a restrictive umask cannot widen it. +func writeProcessID(path string) error { + content := []byte(strconv.Itoa(os.Getpid())) + if err := os.WriteFile(path, content, stateFileMode); err != nil { + return err + } + return os.Chmod(path, stateFileMode) +} + +func errLockHeld(path string) error { + return fmt.Errorf("state: another Cattery process holds %q", path) +} diff --git a/internal/state/lock_test.go b/internal/state/lock_test.go new file mode 100644 index 0000000..5b1cdae --- /dev/null +++ b/internal/state/lock_test.go @@ -0,0 +1,135 @@ +package state + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestStateLock(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"acquire and release", testLockAcquireRelease}, + {"contention fails immediately", testLockContention}, + {"writes process id", testLockWritesProcessID}, + {"rejects symlink lock file", testRejectsSymlinkLock}, + {"rejects non-regular lock file", testRejectsNonRegularLock}, + {"rejects wrong lock mode", testRejectsWrongLockMode}, + {"release is idempotent", testReleaseIsIdempotent}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testLockAcquireRelease(t *testing.T) { + lock := NewLock(tempLockPath(t)) + if err := lock.Acquire(); err != nil { + t.Fatalf("Acquire: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("Release: %v", err) + } + second := NewLock(lock.Path()) + if err := second.Acquire(); err != nil { + t.Fatalf("reacquire after release: %v", err) + } + _ = second.Release() +} + +func testLockContention(t *testing.T) { + path := tempLockPath(t) + first := NewLock(path) + if err := first.Acquire(); err != nil { + t.Fatalf("first Acquire: %v", err) + } + defer first.Release() + second := NewLock(path) + if err := second.Acquire(); err == nil { + t.Fatal("second Acquire succeeded while the lock was held") + } +} + +func testLockWritesProcessID(t *testing.T) { + path := tempLockPath(t) + lock := NewLock(path) + if err := lock.Acquire(); err != nil { + t.Fatalf("Acquire: %v", err) + } + defer lock.Release() + bytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + want := strconv.Itoa(os.Getpid()) + if string(bytes) != want { + t.Fatalf("lock contents = %q, want PID %q", string(bytes), want) + } + if modeOf(t, path) != stateFileMode { + t.Fatalf("lock mode %o, want %o", modeOf(t, path), stateFileMode) + } +} + +func testRejectsSymlinkLock(t *testing.T) { + target := filepath.Join(prepareCatteryDirectory(t), stateLockFileName) + if err := os.Symlink("/dev/null", target); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if err := NewLock(target).Acquire(); err == nil { + t.Fatal("Acquire accepted a symlink lock file") + } +} + +func testRejectsNonRegularLock(t *testing.T) { + target := filepath.Join(prepareCatteryDirectory(t), stateLockFileName) + if err := os.Mkdir(target, stateFileMode); err != nil { + t.Fatal(err) + } + if err := NewLock(target).Acquire(); err == nil { + t.Fatal("Acquire accepted a non-regular lock entry") + } +} + +func testRejectsWrongLockMode(t *testing.T) { + target := filepath.Join(prepareCatteryDirectory(t), stateLockFileName) + createFile(t, target, 0o644) + if err := NewLock(target).Acquire(); err == nil { + t.Fatal("Acquire accepted a lock file with the wrong mode") + } +} + +func testReleaseIsIdempotent(t *testing.T) { + lock := NewLock(tempLockPath(t)) + if err := lock.Acquire(); err != nil { + t.Fatalf("Acquire: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("first Release: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("second Release: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("third Release: %v", err) + } +} + +func tempLockPath(t *testing.T) string { + t.Helper() + return filepath.Join(prepareCatteryDirectory(t), stateLockFileName) +} + +func prepareCatteryDirectory(t *testing.T) string { + t.Helper() + directory := filepath.Join(t.TempDir(), catteryDirectoryName) + if err := os.MkdirAll(directory, stateDirectoryMode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(directory, stateDirectoryMode); err != nil { + t.Fatal(err) + } + return directory +} -- 2.51.2 From 10625c3757953592ebe93401b0e2cc448b200159 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 14:55:57 -0400 Subject: [PATCH 4/5] feat: migrate state schema --- internal/state/migrations.go | 81 ++++++++++++++ internal/state/migrations/001_initial.sql | 49 +++++++++ internal/state/migrations_test.go | 127 ++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 internal/state/migrations.go create mode 100644 internal/state/migrations/001_initial.sql create mode 100644 internal/state/migrations_test.go diff --git a/internal/state/migrations.go b/internal/state/migrations.go new file mode 100644 index 0000000..f2a1f26 --- /dev/null +++ b/internal/state/migrations.go @@ -0,0 +1,81 @@ +package state + +import ( + "database/sql" + "fmt" + + _ "embed" +) + +// initialMigrationSQL is the embedded Section 8.4 schema. It is one of the two +// package-variable exceptions permitted by Section 12.1. +// +//go:embed migrations/001_initial.sql +var initialMigrationSQL string + +// currentSchemaVersion is the schema version this build's migrations produce. +// PRAGMA user_version is managed against it. +const currentSchemaVersion = 1 + +// Migrate applies the embedded schema migration to the database when needed. +// Re-running on a current database is a no-op. An unknown newer schema +// (user_version greater than current) is rejected so a newer Cattery never +// silently corrupts an older binary's state. +func Migrate(database *Database) error { + version, err := readUserVersion(database) + if err != nil { + return err + } + if version > currentSchemaVersion { + return errUnknownSchema(version) + } + if version == currentSchemaVersion { + return nil + } + return applyMigration(database) +} + +func applyMigration(database *Database) error { + transaction, err := beginExclusive(database) + if err != nil { + return err + } + if _, err := transaction.Exec(initialMigrationSQL); err != nil { + _ = transaction.Rollback() + return fmt.Errorf("state: apply migration: %w", err) + } + if err := setUserVersion(transaction, currentSchemaVersion); err != nil { + _ = transaction.Rollback() + return err + } + return transaction.Commit() +} + +func beginExclusive(database *Database) (*sql.Tx, error) { + if _, err := database.conn.Exec("PRAGMA locking_mode = EXCLUSIVE"); err != nil { + return nil, fmt.Errorf("state: exclusive locking mode: %w", err) + } + return database.conn.Begin() +} + +func readUserVersion(database *Database) (int, error) { + var version int + if err := database.conn.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + return 0, fmt.Errorf("state: read user_version: %w", err) + } + return version, nil +} + +func setUserVersion(transaction *sql.Tx, version int) error { + statement := fmt.Sprintf("PRAGMA user_version = %d", version) + if _, err := transaction.Exec(statement); err != nil { + return fmt.Errorf("state: set user_version: %w", err) + } + return nil +} + +func errUnknownSchema(version int) error { + return fmt.Errorf( + "state: database schema version %d is newer than supported version %d", + version, currentSchemaVersion) +} diff --git a/internal/state/migrations/001_initial.sql b/internal/state/migrations/001_initial.sql new file mode 100644 index 0000000..466747e --- /dev/null +++ b/internal/state/migrations/001_initial.sql @@ -0,0 +1,49 @@ +CREATE TABLE metadata ( + key TEXT PRIMARY KEY, + value BLOB NOT NULL +); + +CREATE TABLE repositories ( + id INTEGER PRIMARY KEY, + root_path TEXT NOT NULL, + home_path TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)), + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + UNIQUE (root_path, home_path) +); + +CREATE UNIQUE INDEX repositories_one_default +ON repositories(home_path) +WHERE is_default = 1; + +CREATE TABLE files ( + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + target_path TEXT NOT NULL, + group_name TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL, + source_kind TEXT NOT NULL CHECK (source_kind IN ('ordinary', 'secret')), + layer TEXT NOT NULL CHECK (layer IN ('base', 'darwin', 'linux')), + baseline_content_hash BLOB NOT NULL CHECK (length(baseline_content_hash) = 32), + baseline_source_hash BLOB NOT NULL CHECK (length(baseline_source_hash) = 32), + executable_bits INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'retired')), + applied_at TEXT NOT NULL, + retired_at TEXT, + PRIMARY KEY (repository_id, target_path) +); + +CREATE INDEX files_by_scope +ON files(repository_id, group_name, status); + +CREATE TABLE aliases ( + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + alias_path TEXT NOT NULL, + canonical_target_path TEXT NOT NULL, + group_name TEXT NOT NULL DEFAULT '', + layer TEXT NOT NULL CHECK (layer IN ('all', 'darwin', 'linux')), + status TEXT NOT NULL CHECK (status IN ('active', 'retired')), + applied_at TEXT NOT NULL, + retired_at TEXT, + PRIMARY KEY (repository_id, alias_path) +); diff --git a/internal/state/migrations_test.go b/internal/state/migrations_test.go new file mode 100644 index 0000000..3b2183d --- /dev/null +++ b/internal/state/migrations_test.go @@ -0,0 +1,127 @@ +package state + +import ( + "database/sql" + "fmt" + "testing" +) + +func TestStateMigration(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"fresh database applies", testMigrationFresh}, + {"current database is no-op", testMigrationIdempotent}, + {"forced failure rolls back", testMigrationRollback}, + {"interrupted migration recovers", testMigrationRecoversAfterInterruption}, + {"newer schema rejected", testMigrationRejectsNewer}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testMigrationFresh(t *testing.T) { + database := openTestDatabase(t) + defer database.Close() + if err := Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + assertSchemaCurrent(t, database.conn) +} + +func testMigrationIdempotent(t *testing.T) { + database := openTestDatabase(t) + defer database.Close() + if err := Migrate(database); err != nil { + t.Fatalf("first Migrate: %v", err) + } + if err := Migrate(database); err != nil { + t.Fatalf("second Migrate: %v", err) + } + assertSchemaCurrent(t, database.conn) +} + +func testMigrationRollback(t *testing.T) { + database := openTestDatabase(t) + defer database.Close() + execOn(t, database.conn, "CREATE TABLE metadata (key TEXT)") + if err := Migrate(database); err == nil { + t.Fatal("Migrate succeeded against a conflicting schema") + } + if version := userVersion(t, database.conn); version != 0 { + t.Fatalf("user_version = %d after rollback, want 0", version) + } + if tableExists(t, database.conn, "repositories") { + t.Fatal("rollback left a partial repositories table") + } +} + +func testMigrationRecoversAfterInterruption(t *testing.T) { + database := openTestDatabase(t) + defer database.Close() + execOn(t, database.conn, "CREATE TABLE metadata (key TEXT)") + if err := Migrate(database); err == nil { + t.Fatal("Migrate succeeded against an interrupted schema") + } + execOn(t, database.conn, "DROP TABLE metadata") + if err := Migrate(database); err != nil { + t.Fatalf("Migrate after interruption cleanup: %v", err) + } + assertSchemaCurrent(t, database.conn) +} + +func testMigrationRejectsNewer(t *testing.T) { + database := openTestDatabase(t) + defer database.Close() + statement := fmt.Sprintf("PRAGMA user_version = %d", currentSchemaVersion+1) + execOn(t, database.conn, statement) + if err := Migrate(database); err == nil { + t.Fatal("Migrate accepted a newer schema version") + } + if tableExists(t, database.conn, "metadata") { + t.Fatal("newer-schema rejection must not create any tables") + } +} + +func assertSchemaCurrent(t *testing.T, conn *sql.DB) { + t.Helper() + if version := userVersion(t, conn); version != currentSchemaVersion { + t.Fatalf("user_version = %d, want %d", version, currentSchemaVersion) + } + for _, table := range []string{"metadata", "repositories", "files", "aliases"} { + if !tableExists(t, conn, table) { + t.Fatalf("table %q missing after migration", table) + } + } +} + +func userVersion(t *testing.T, conn *sql.DB) int { + t.Helper() + var version int + if err := conn.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + t.Fatalf("read user_version: %v", err) + } + return version +} + +func tableExists(t *testing.T, conn *sql.DB, name string) bool { + t.Helper() + var found string + query := "SELECT name FROM sqlite_master WHERE type = ? AND name = ?" + if err := conn.QueryRow(query, "table", name).Scan(&found); err != nil { + if err == sql.ErrNoRows { + return false + } + t.Fatalf("query table %s: %v", name, err) + } + return found == name +} + +func execOn(t *testing.T, conn *sql.DB, statement string) { + t.Helper() + if _, err := conn.Exec(statement); err != nil { + t.Fatalf("%q: %v", statement, err) + } +} -- 2.51.2 From c3c2caa5d0a6c4cc9110d496fce059ff2882adc4 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 14:58:42 -0400 Subject: [PATCH 5/5] feat: assemble state store --- internal/state/store.go | 121 ++++++++++++++++++++ internal/state/store_test.go | 209 +++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 internal/state/store.go create mode 100644 internal/state/store_test.go diff --git a/internal/state/store.go b/internal/state/store.go new file mode 100644 index 0000000..73a289f --- /dev/null +++ b/internal/state/store.go @@ -0,0 +1,121 @@ +package state + +import ( + "context" + "path/filepath" +) + +// Dependencies bundles the injectable seams of a Store. StateHome, when set, +// overrides the XDG-derived state directory; when empty, resolution reads +// XDG_STATE_HOME and rejects a relative value. Clock supplies timestamps and +// defaults to SystemClock when nil. +type Dependencies struct { + StateHome string + Clock Clock +} + +// Store coordinates the state lifecycle: path resolution, advisory locking, +// database opening, and schema migration. Construction performs no filesystem +// access; Acquire performs all of it in the only permitted order and Close +// reverses it. +type Store struct { + stateHome string + clock Clock + lock *Lock + database *Database +} + +// NewStore constructs a lazy Store bound to the injected dependencies. It opens +// no SQLite connection, acquires no lock, creates no path, and inspects no +// repository: every effect begins inside Acquire. +func NewStore(deps Dependencies) *Store { + clock := deps.Clock + if clock == nil { + clock = SystemClock{} + } + return &Store{stateHome: deps.StateHome, clock: clock} +} + +// Clock returns the clock the store records timestamps with. It is never nil. +func (store *Store) Clock() Clock { + return store.clock +} + +// Database returns the opened database handle, or nil before Acquire succeeds. +func (store *Store) Database() *Database { + return store.database +} + +// Acquire resolves the canonical state directory, creates it, acquires the +// advisory lock, opens the database, and applies any required migration. On any +// failure it releases what was acquired so the Store is safe to drop. +func (store *Store) Acquire(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + directory, err := store.catteryDirectory() + if err != nil { + return err + } + if err := prepareStateDirectory(directory); err != nil { + return err + } + store.lock = NewLock(filepath.Join(directory, stateLockFileName)) + if err := store.lock.Acquire(); err != nil { + store.lock = nil + return err + } + if err := store.openDatabase(directory); err != nil { + store.releaseAfterFailedAcquire() + return err + } + return nil +} + +// Close releases the database connection and advisory lock in reverse +// acquisition order. It is safe to call on a Store that never Acquired. +func (store *Store) Close() error { + var first error + if store.database != nil { + first = store.database.Close() + store.database = nil + } + if store.lock != nil { + releaseErr := store.lock.Release() + if releaseErr != nil && first == nil { + first = releaseErr + } + store.lock = nil + } + return first +} + +func (store *Store) openDatabase(directory string) error { + store.database = NewDatabase(filepath.Join(directory, stateDatabaseFileName)) + if err := store.database.Open(); err != nil { + return err + } + if err := Migrate(store.database); err != nil { + return err + } + return nil +} + +func (store *Store) releaseAfterFailedAcquire() { + if store.database != nil { + _ = store.database.Close() + store.database = nil + } + if store.lock != nil { + _ = store.lock.Release() + store.lock = nil + } +} + +func (store *Store) catteryDirectory() (string, error) { + home, err := resolveStateHome(store.stateHome) + if err != nil { + return "", err + } + return resolveCatteryDirectory(home) +} diff --git a/internal/state/store_test.go b/internal/state/store_test.go new file mode 100644 index 0000000..53e07d4 --- /dev/null +++ b/internal/state/store_test.go @@ -0,0 +1,209 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestStoreLifecycle(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"construction touches no filesystem", testStoreConstructionClean}, + {"acquire opens and closes", testStoreAcquireAndClose}, + {"acquire creates no managed rows", testStoreAcquireLeavesNoRows}, + {"path resolve failure", testStorePathResolveFailure}, + {"lock failure leaves no database", testStoreLockFailure}, + {"open failure releases lock", testStoreOpenFailure}, + {"migrate failure releases lock", testStoreMigrateFailure}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testStoreConstructionClean(t *testing.T) { + deps := tempDependencies(t) + store := NewStore(deps) + if store.Database() != nil { + t.Fatal("Database non-nil before Acquire") + } + if store.Clock() == nil { + t.Fatal("Clock nil") + } + if _, err := os.Stat(catteryDirFor(t, deps)); !os.IsNotExist(err) { + t.Fatalf("NewStore created the cattery directory before Acquire: %v", err) + } +} + +func testStoreAcquireAndClose(t *testing.T) { + store := NewStore(tempDependencies(t)) + if err := store.Acquire(context.Background()); err != nil { + t.Fatalf("Acquire: %v", err) + } + if store.Database() == nil { + t.Fatal("Database nil after Acquire") + } + if err := store.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if store.Database() != nil { + t.Fatal("Database non-nil after Close") + } + if err := store.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } +} + +func testStoreAcquireLeavesNoRows(t *testing.T) { + store := NewStore(tempDependencies(t)) + if err := store.Acquire(context.Background()); err != nil { + t.Fatalf("Acquire: %v", err) + } + defer store.Close() + conn := store.Database().conn + for _, table := range []string{"repositories", "files", "aliases"} { + if count := rowCount(t, conn, table); count != 0 { + t.Fatalf("table %s has %d rows after Acquire, want 0", table, count) + } + } +} + +func testStorePathResolveFailure(t *testing.T) { + store := NewStore(Dependencies{StateHome: "relative/path"}) + if err := store.Acquire(context.Background()); err == nil { + t.Fatal("Acquire accepted a relative state home") + } + if err := store.Close(); err != nil { + t.Fatalf("Close after failed Acquire: %v", err) + } +} + +func testStoreLockFailure(t *testing.T) { + deps := tempDependencies(t) + directory := ensureCatteryDir(t, deps) + holder := NewLock(filepath.Join(directory, stateLockFileName)) + if err := holder.Acquire(); err != nil { + t.Fatalf("holder Acquire: %v", err) + } + defer holder.Release() + store := NewStore(deps) + if err := store.Acquire(context.Background()); err == nil { + t.Fatal("Acquire succeeded while another holder held the lock") + } + if err := store.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if pathExists(t, databasePathFor(t, deps)) { + t.Fatal("database file created while the lock was held") + } +} + +func testStoreOpenFailure(t *testing.T) { + deps := tempDependencies(t) + directory := ensureCatteryDir(t, deps) + if err := os.Mkdir(databasePathFor(t, deps), stateFileMode); err != nil { + t.Fatal(err) + } + store := NewStore(deps) + if err := store.Acquire(context.Background()); err == nil { + t.Fatal("Acquire accepted a non-regular database entry") + } + if err := store.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + replacer := NewLock(filepath.Join(directory, stateLockFileName)) + if err := replacer.Acquire(); err != nil { + t.Fatalf("lock not released after open failure: %v", err) + } + _ = replacer.Release() +} + +func testStoreMigrateFailure(t *testing.T) { + deps := tempDependencies(t) + seedNewerSchema(t, deps) + store := NewStore(deps) + if err := store.Acquire(context.Background()); err == nil { + t.Fatal("Acquire succeeded against a newer schema") + } + if err := store.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + replacer := NewLock(filepath.Join(catteryDirFor(t, deps), stateLockFileName)) + if err := replacer.Acquire(); err != nil { + t.Fatalf("lock not released after migrate failure: %v", err) + } + _ = replacer.Release() +} + +func tempDependencies(t *testing.T) Dependencies { + t.Helper() + return Dependencies{StateHome: t.TempDir(), Clock: SystemClock{}} +} + +func catteryDirFor(t *testing.T, deps Dependencies) string { + t.Helper() + directory, err := resolveCatteryDirectory(deps.StateHome) + if err != nil { + t.Fatal(err) + } + return directory +} + +func ensureCatteryDir(t *testing.T, deps Dependencies) string { + t.Helper() + directory := catteryDirFor(t, deps) + if err := os.MkdirAll(directory, stateDirectoryMode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(directory, stateDirectoryMode); err != nil { + t.Fatal(err) + } + return directory +} + +func databasePathFor(t *testing.T, deps Dependencies) string { + t.Helper() + return filepath.Join(catteryDirFor(t, deps), stateDatabaseFileName) +} + +func seedNewerSchema(t *testing.T, deps Dependencies) { + t.Helper() + database := NewDatabase(databasePathFor(t, deps)) + if err := database.Open(); err != nil { + t.Fatal(err) + } + statement := fmt.Sprintf("PRAGMA user_version = %d", currentSchemaVersion+1) + execOn(t, database.conn, statement) + if err := database.Close(); err != nil { + t.Fatal(err) + } +} + +func pathExists(t *testing.T, path string) bool { + t.Helper() + _, err := os.Lstat(path) + if err == nil { + return true + } + if os.IsNotExist(err) { + return false + } + t.Fatalf("Lstat %q: %v", path, err) + return false +} + +func rowCount(t *testing.T, conn *sql.DB, table string) int64 { + t.Helper() + var count int64 + query := "SELECT COUNT(*) FROM " + table + if err := conn.QueryRow(query).Scan(&count); err != nil { + t.Fatalf("count %s: %v", table, err) + } + return count +}