//go:build unix package instanceguard import ( "errors" "os" "os/exec" "path/filepath" "testing" "time" "agent-system-backend/internal/store" ) func TestAcquireSameResourceAcrossAliases(t *testing.T) { base := t.TempDir() real := filepath.Join(base, "real") if err := os.Mkdir(real, 0o755); err != nil { t.Fatal(err) } link := filepath.Join(base, "link") if err := os.Symlink(real, link); err != nil { t.Fatal(err) } held, err := Acquire(link) if err != nil { t.Fatal(err) } defer held.Release() if _, err := Acquire(real); !errors.Is(err, ErrBusy) { t.Fatalf("symlink alias: %v", err) } wd, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chdir(base); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(wd) }) if _, err := Acquire(filepath.Join(".", "real")); !errors.Is(err, ErrBusy) { t.Fatalf("relative path: %v", err) } } func TestHardlinkRejected(t *testing.T) { dir := t.TempDir() db := filepath.Join(dir, "agent-system.db") st, err := store.OpenSQLite(db) if err != nil { t.Fatal(err) } if err := st.Close(); err != nil { t.Fatal(err) } if err := os.Link(db, filepath.Join(dir, "alias.db")); err != nil { t.Fatal(err) } if _, err := Acquire(dir); !errors.Is(err, ErrHardlink) { t.Fatalf("hardlink: %v", err) } } func TestDatabaseReplacementRefusedBeforeOpen(t *testing.T) { dir := t.TempDir() st, release, err := openAndBind(t, dir, "") if err != nil { t.Fatal(err) } db := filepath.Join(dir, "agent-system.db") release() _ = st if err := os.Remove(db); err != nil { t.Fatal(err) } raw, err := os.ReadFile(db + ".lock") if err != nil { t.Fatal(err) } rec, err := parseRecord(string(raw)) if err != nil || rec == nil { t.Fatal(err) } for i := 0; i < 16; i++ { if err := os.WriteFile(db, []byte("replaced"), 0o600); err != nil { t.Fatal(err) } _, ino, exists, err := statFile(db) if err != nil { t.Fatal(err) } if exists && ino != rec.DBIno { break } if err := os.Remove(db); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "inode-burn-"+string(rune('a'+i))), []byte("x"), 0o600); err != nil { t.Fatal(err) } } lease, err := Acquire(dir) if err != nil { t.Fatal(err) } defer lease.Release() if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBReplaced) { t.Fatalf("replaced db: %v", err) } if _, err := os.Stat(db); err != nil { t.Fatal(err) } } func TestMissingRecordedDatabaseRefused(t *testing.T) { dir := t.TempDir() _, release, err := openAndBind(t, dir, "") if err != nil { t.Fatal(err) } release() db := filepath.Join(dir, "agent-system.db") if err := os.Remove(db); err != nil { t.Fatal(err) } lease, err := Acquire(dir) if err != nil { t.Fatal(err) } defer lease.Release() if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBMissing) { t.Fatalf("missing db: %v", err) } } func TestRebuiltLockRefusedUntilAdopt(t *testing.T) { dir := t.TempDir() st, release, err := openAndBind(t, dir, "inst-a") if err != nil { t.Fatal(err) } release() _ = st.Close() if err := os.Remove(filepath.Join(dir, "agent-system.db.lock")); err != nil { t.Fatal(err) } lease, err := Acquire(dir) if err != nil { t.Fatal(err) } if err := lease.AutomaticRefusal(); err != nil { lease.Release() t.Fatal(err) } opened, err := store.OpenSQLite(lease.DBPath()) if err != nil { lease.Release() t.Fatal(err) } err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"}) _ = opened.Close() lease.Release() if !errors.Is(err, ErrLockRebuilt) { t.Fatalf("rebuilt lock: %v", err) } adopted, err := Acquire(dir) if err != nil { t.Fatal(err) } defer adopted.Release() opened, err = store.OpenSQLite(adopted.DBPath()) if err != nil { t.Fatal(err) } defer opened.Close() if err := Bind(opened, adopted, BindOptions{Adopt: true, InstanceID: "inst-a"}); err != nil { t.Fatal(err) } } func TestClearedLockRecordRefusedWhenInodeStays(t *testing.T) { dir := t.TempDir() st, release, err := openAndBind(t, dir, "inst-a") if err != nil { t.Fatal(err) } release() _ = st.Close() lockPath := filepath.Join(dir, "agent-system.db.lock") if err := os.Truncate(lockPath, 0); err != nil { t.Fatal(err) } lease, err := Acquire(dir) if err != nil { t.Fatal(err) } opened, err := store.OpenSQLite(lease.DBPath()) if err != nil { lease.Release() t.Fatal(err) } err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"}) _ = opened.Close() lease.Release() if !errors.Is(err, ErrLockRebuilt) { t.Fatalf("cleared lock: %v", err) } } func TestInstanceMismatchPreservesEmpty(t *testing.T) { dir := t.TempDir() st, release, err := openAndBind(t, dir, "inst-a") if err != nil { t.Fatal(err) } release() _ = st.Close() lease, err := Acquire(dir) if err != nil { t.Fatal(err) } opened, err := store.OpenSQLite(lease.DBPath()) if err != nil { lease.Release() t.Fatal(err) } err = Bind(opened, lease, BindOptions{InstanceID: "inst-b"}) _ = opened.Close() lease.Release() if !errors.Is(err, ErrInstanceMismatch) { t.Fatalf("mismatch: %v", err) } lease, err = Acquire(dir) if err != nil { t.Fatal(err) } defer lease.Release() opened, err = store.OpenSQLite(lease.DBPath()) if err != nil { t.Fatal(err) } defer opened.Close() if err := Bind(opened, lease, BindOptions{}); err != nil { t.Fatal(err) } marker, ok, err := opened.LoadInstanceGuard() if err != nil || !ok || marker.InstanceID != "inst-a" { t.Fatalf("empty id should keep marker: ok=%v id=%q err=%v", ok, marker.InstanceID, err) } } func TestLockReleasedAfterKill(t *testing.T) { if os.Getenv("U8_GUARD_ROLE") == "hold" { lease, err := Acquire(os.Getenv("U8_GUARD_DIR")) if err != nil { t.Fatal(err) } if err := os.WriteFile(os.Getenv("U8_GUARD_READY"), []byte("ready"), 0o600); err != nil { t.Fatal(err) } time.Sleep(30 * time.Second) lease.Release() return } dir := t.TempDir() ready := filepath.Join(dir, "ready") cmd := exec.Command(os.Args[0], "-test.run=TestLockReleasedAfterKill") cmd.Env = append(os.Environ(), "U8_GUARD_ROLE=hold", "U8_GUARD_DIR="+dir, "U8_GUARD_READY="+ready) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { if cmd.Process != nil { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() } }) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(ready); err == nil { break } time.Sleep(20 * time.Millisecond) } if _, err := os.Stat(ready); err != nil { t.Fatal(err) } if _, err := Acquire(dir); !errors.Is(err, ErrBusy) { t.Fatalf("held: %v", err) } if err := cmd.Process.Kill(); err != nil { t.Fatal(err) } _, _ = cmd.Process.Wait() lease, err := Acquire(dir) if err != nil { t.Fatal(err) } lease.Release() } func TestMissingDataDirIsCreatedBeforeLock(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "data") lease, err := Acquire(dir) if err != nil { t.Fatal(err) } lease.Release() if _, err := os.Stat(dir); err != nil { t.Fatal(err) } } func TestLockSymlinkCannotAliasDatabase(t *testing.T) { dir := t.TempDir() db := filepath.Join(dir, "agent-system.db") st, err := store.OpenSQLite(db) if err != nil { t.Fatal(err) } if err := st.Close(); err != nil { t.Fatal(err) } if err := os.Remove(db + ".lock"); err != nil && !os.IsNotExist(err) { t.Fatal(err) } if err := os.Symlink(db, db+".lock"); err != nil { t.Fatal(err) } before, err := os.ReadFile(db) if err != nil { t.Fatal(err) } if _, err := Acquire(dir); !errors.Is(err, ErrLockAliased) { t.Fatalf("symlink lock: %v", err) } after, err := os.ReadFile(db) if err != nil { t.Fatal(err) } if string(before) != string(after) { t.Fatal("aliased lock wrote into the database") } } func TestSymlinkedDatabaseLocksResolvedFile(t *testing.T) { base := t.TempDir() realDir := filepath.Join(base, "real") aliasDir := filepath.Join(base, "alias") if err := os.Mkdir(realDir, 0o755); err != nil { t.Fatal(err) } if err := os.Mkdir(aliasDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(realDir, "agent-system.db"), []byte{}, 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(realDir, "agent-system.db"), filepath.Join(aliasDir, "agent-system.db")); err != nil { t.Fatal(err) } held, err := Acquire(aliasDir) if err != nil { t.Fatal(err) } defer held.Release() if _, err := os.Stat(filepath.Join(realDir, "agent-system.db.lock")); err != nil { t.Fatal(err) } if _, err := Acquire(realDir); !errors.Is(err, ErrBusy) { t.Fatalf("resolved db lock: %v", err) } } func openAndBind(t *testing.T, dir, instanceID string) (*store.Store, func(), error) { t.Helper() lease, err := Acquire(dir) if err != nil { return nil, nil, err } if err := lease.AutomaticRefusal(); err != nil { lease.Release() return nil, nil, err } st, err := store.OpenSQLite(lease.DBPath()) if err != nil { lease.Release() return nil, nil, err } if err := Bind(st, lease, BindOptions{InstanceID: instanceID}); err != nil { _ = st.Close() lease.Release() return nil, nil, err } return st, func() { _ = st.Close() lease.Release() }, nil }