@@ -0,0 +1,286 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package session
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base32"
+ "errors"
+ "fmt"
+ "io"
+ "time"
+
+ "git.secluded.site/np/internal/db"
+ "git.secluded.site/np/internal/timeutil"
+)
+
+// State captures the lifecycle of a session.
+type State string
+
+const (
+ // StateActive indicates that the session is currently active for a directory.
+ StateActive State = "active"
+ // StateArchived indicates that the session has been archived.
+ StateArchived State = "archived"
+)
+
+// Document represents the persisted session metadata.
+type Document struct {
+ SID string `json:"sid"`
+ DirPath string `json:"dir_path"`
+ DirHash string `json:"dir_hash"`
+ State State `json:"state"`
+ CreatedAt time.Time `json:"created_at"`
+ ArchivedAt *time.Time `json:"archived_at"`
+ LastUpdatedAt time.Time `json:"last_updated_at"`
+}
+
+// ErrNotFound indicates that a session could not be located.
+var ErrNotFound = errors.New("session: not found")
+
+// AlreadyActiveError is returned when attempting to start a session where one already exists.
+type AlreadyActiveError struct {
+ Session Document
+}
+
+func (e AlreadyActiveError) Error() string {
+ return "session: active session already exists for directory"
+}
+
+// Store provides helpers for session lifecycle management.
+type Store struct {
+ db *db.Database
+ clock timeutil.Clock
+}
+
+// NewStore constructs a Store. When clock is nil, a UTC clock is used.
+func NewStore(database *db.Database, clock timeutil.Clock) *Store {
+ if clock == nil {
+ clock = timeutil.UTCClock{}
+ }
+ return &Store{
+ db: database,
+ clock: clock,
+ }
+}
+
+// Start creates a new session bound to path. When a session already exists for
+// the directory, an AlreadyActiveError is returned containing the existing
+// session document.
+func (s *Store) Start(ctx context.Context, path string) (Document, error) {
+ if s.db == nil {
+ return Document{}, errors.New("session: database is nil")
+ }
+
+ canonical, hash, err := db.CanonicalizeAndHash(path)
+ if err != nil {
+ return Document{}, fmt.Errorf("session: canonicalise path: %w", err)
+ }
+
+ var doc Document
+ err = s.db.Update(ctx, func(txn *db.Txn) error {
+ keyActive := db.KeyDirActive(hash)
+ exists, err := txn.Exists(keyActive)
+ if err != nil {
+ return err
+ }
+ if exists {
+ sidBytes, err := txn.Get(keyActive)
+ if err != nil {
+ return err
+ }
+ sid := string(sidBytes)
+ metaKey := db.KeySessionMeta(sid)
+ already, err := txn.Exists(metaKey)
+ if err != nil {
+ return err
+ }
+ if !already {
+ return fmt.Errorf("session: active session %q missing metadata", sid)
+ }
+ var existing Document
+ if err := txn.GetJSON(metaKey, &existing); err != nil {
+ return err
+ }
+ return AlreadyActiveError{Session: existing}
+ }
+
+ now := timeutil.EnsureUTC(s.clock.Now())
+ sid, err := newSessionID(now)
+ if err != nil {
+ return err
+ }
+
+ doc = Document{
+ SID: sid,
+ DirPath: canonical,
+ DirHash: hash,
+ State: StateActive,
+ CreatedAt: now,
+ ArchivedAt: nil,
+ LastUpdatedAt: now,
+ }
+
+ if err := txn.SetJSON(db.KeySessionMeta(sid), doc); err != nil {
+ return err
+ }
+ if err := txn.Set(db.KeyDirActive(hash), []byte(sid)); err != nil {
+ return err
+ }
+ if err := txn.Set(db.KeyIdxActive(sid), []byte(hash)); err != nil {
+ return err
+ }
+ if err := txn.Set(db.KeySessionEventSeq(sid), make([]byte, 8)); err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ var already AlreadyActiveError
+ if errors.As(err, &already) {
+ return already.Session, already
+ }
+ return Document{}, err
+ }
+
+ return doc, nil
+}
+
+// Get retrieves a session by SID.
+func (s *Store) Get(ctx context.Context, sid string) (Document, error) {
+ var doc Document
+ err := s.db.View(ctx, func(txn *db.Txn) error {
+ metaKey := db.KeySessionMeta(sid)
+ exists, err := txn.Exists(metaKey)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return ErrNotFound
+ }
+ return txn.GetJSON(metaKey, &doc)
+ })
+ return doc, err
+}
+
+var crockfordEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
+
+func newSessionID(now time.Time) (string, error) {
+ ms := uint64(now.UnixMilli())
+
+ var data [16]byte
+ data[0] = byte(ms >> 40)
+ data[1] = byte(ms >> 32)
+ data[2] = byte(ms >> 24)
+ data[3] = byte(ms >> 16)
+ data[4] = byte(ms >> 8)
+ data[5] = byte(ms)
+
+ if _, err := io.ReadFull(rand.Reader, data[6:]); err != nil {
+ return "", fmt.Errorf("session: generate randomness: %w", err)
+ }
+
+ id := crockfordEncoding.EncodeToString(data[:])
+ if len(id) != 26 {
+ return "", fmt.Errorf("session: unexpected ulid length %d", len(id))
+ }
+ return id, nil
+}
+
+// ActiveByPath returns the active session for path or any of its parents. The
+// returned boolean reports whether a session was found.
+func (s *Store) ActiveByPath(ctx context.Context, path string) (Document, bool, error) {
+ canonical, err := db.CanonicalizeDir(path)
+ if err != nil {
+ return Document{}, false, fmt.Errorf("session: canonicalise path: %w", err)
+ }
+
+ var doc Document
+ found := false
+
+ err = s.db.View(ctx, func(txn *db.Txn) error {
+ for _, candidate := range db.ParentWalk(canonical) {
+ hash := db.DirHash(candidate)
+ keyActive := db.KeyDirActive(hash)
+
+ exists, err := txn.Exists(keyActive)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ continue
+ }
+
+ sidBytes, err := txn.Get(keyActive)
+ if err != nil {
+ return err
+ }
+ metaKey := db.KeySessionMeta(string(sidBytes))
+ if err := txn.GetJSON(metaKey, &doc); err != nil {
+ return err
+ }
+ found = true
+ return nil
+ }
+ return nil
+ })
+ if err != nil {
+ return Document{}, false, err
+ }
+ return doc, found, nil
+}
+
+// Archive transitions sid to the archived state. When the session is already
+// archived, the stored document is returned without error.
+func (s *Store) Archive(ctx context.Context, sid string) (Document, error) {
+ if s.db == nil {
+ return Document{}, errors.New("session: database is nil")
+ }
+
+ var doc Document
+ err := s.db.Update(ctx, func(txn *db.Txn) error {
+ metaKey := db.KeySessionMeta(sid)
+ exists, err := txn.Exists(metaKey)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return ErrNotFound
+ }
+ if err := txn.GetJSON(metaKey, &doc); err != nil {
+ return err
+ }
+ if doc.State == StateArchived {
+ return nil
+ }
+
+ now := timeutil.EnsureUTC(s.clock.Now())
+ doc.State = StateArchived
+ doc.LastUpdatedAt = now
+ doc.ArchivedAt = &now
+
+ if err := txn.Delete(db.KeyDirActive(doc.DirHash)); err != nil {
+ return err
+ }
+ if err := txn.Delete(db.KeyIdxActive(doc.SID)); err != nil {
+ return err
+ }
+
+ tsHex := db.Uint64Hex(uint64(now.UnixNano()))
+ if err := txn.Set(db.KeyIdxArchived(tsHex, doc.SID), []byte(doc.DirHash)); err != nil {
+ return err
+ }
+ if err := txn.Set(db.KeyDirArchived(doc.DirHash, tsHex, doc.SID), []byte{}); err != nil {
+ return err
+ }
+ if err := txn.SetJSON(metaKey, doc); err != nil {
+ return err
+ }
+ return nil
+ })
+
+ return doc, err
+}
@@ -0,0 +1,193 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package session_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "git.secluded.site/np/internal/db"
+ "git.secluded.site/np/internal/session"
+ "git.secluded.site/np/internal/testutil"
+)
+
+func TestStoreStartAndArchive(t *testing.T) {
+ ctx := context.Background()
+ database := testutil.OpenDB(t)
+
+ clock := &testutil.SequenceClock{
+ Times: []time.Time{
+ time.Date(2025, time.April, 4, 9, 15, 0, 0, time.FixedZone("A", 3600)),
+ time.Date(2025, time.April, 4, 11, 45, 0, 0, time.FixedZone("B", -3600)),
+ time.Date(2025, time.April, 4, 12, 0, 0, 0, time.UTC),
+ },
+ }
+
+ store := session.NewStore(database, clock)
+
+ workdir := t.TempDir()
+ canonical, hash, err := db.CanonicalizeAndHash(workdir)
+ if err != nil {
+ t.Fatalf("canonicalise temp dir: %v", err)
+ }
+
+ started, err := store.Start(ctx, workdir)
+ if err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+
+ if started.SID == "" {
+ t.Fatalf("expected SID to be populated")
+ }
+ if len(started.SID) != 26 {
+ t.Fatalf("expected ULID SID length 26, got %d", len(started.SID))
+ }
+ if started.DirPath != canonical {
+ t.Fatalf("DirPath mismatch: want %q got %q", canonical, started.DirPath)
+ }
+ if started.DirHash != hash {
+ t.Fatalf("DirHash mismatch: want %q got %q", hash, started.DirHash)
+ }
+ if started.State != session.StateActive {
+ t.Fatalf("expected state active, got %s", started.State)
+ }
+ if started.ArchivedAt != nil {
+ t.Fatalf("expected ArchivedAt to be nil")
+ }
+
+ createdAt := clock.Times[0].UTC()
+ if !started.CreatedAt.Equal(createdAt) || !started.LastUpdatedAt.Equal(createdAt) {
+ t.Fatalf("timestamp mismatch: created=%v updated=%v want=%v", started.CreatedAt, started.LastUpdatedAt, createdAt)
+ }
+
+ err = database.View(ctx, func(txn *db.Txn) error {
+ active, err := txn.Get(db.KeyDirActive(hash))
+ if err != nil {
+ return err
+ }
+ if string(active) != started.SID {
+ t.Fatalf("active SID mismatch: got %s want %s", string(active), started.SID)
+ }
+
+ idxActive, err := txn.Get(db.KeyIdxActive(started.SID))
+ if err != nil {
+ return err
+ }
+ if string(idxActive) != hash {
+ t.Fatalf("idx active value mismatch: got %s want %s", string(idxActive), hash)
+ }
+
+ seq, err := txn.Get(db.KeySessionEventSeq(started.SID))
+ if err != nil {
+ return err
+ }
+ if len(seq) != 8 {
+ t.Fatalf("expected event seq zero buffer length 8, got %d", len(seq))
+ }
+ for i, b := range seq {
+ if b != 0 {
+ t.Fatalf("expected zeroed event seq, byte %d was %d", i, b)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("validate stored keys: %v", err)
+ }
+
+ _, err = store.Start(ctx, workdir)
+ if err == nil {
+ t.Fatalf("expected AlreadyActiveError")
+ }
+ var already session.AlreadyActiveError
+ if !errors.As(err, &already) {
+ t.Fatalf("expected AlreadyActiveError, got %v", err)
+ }
+ if already.Session.SID != started.SID {
+ t.Fatalf("AlreadyActiveError returned unexpected SID: %s", already.Session.SID)
+ }
+
+ childDir := filepath.Join(workdir, "nested")
+ if err := os.MkdirAll(childDir, 0o755); err != nil {
+ t.Fatalf("mkdir nested: %v", err)
+ }
+
+ found, ok, err := store.ActiveByPath(ctx, childDir)
+ if err != nil {
+ t.Fatalf("ActiveByPath child: %v", err)
+ }
+ if !ok {
+ t.Fatalf("expected ActiveByPath to find parent session")
+ }
+ if found.SID != started.SID {
+ t.Fatalf("ActiveByPath returned different SID: %s", found.SID)
+ }
+
+ archived, err := store.Archive(ctx, started.SID)
+ if err != nil {
+ t.Fatalf("Archive: %v", err)
+ }
+ if archived.State != session.StateArchived {
+ t.Fatalf("expected archived state, got %s", archived.State)
+ }
+ wantArchivedAt := clock.Times[1].UTC()
+ if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(wantArchivedAt) {
+ t.Fatalf("ArchivedAt mismatch: got %v want %v", archived.ArchivedAt, wantArchivedAt)
+ }
+ if !archived.LastUpdatedAt.Equal(wantArchivedAt) {
+ t.Fatalf("LastUpdatedAt mismatch: got %v want %v", archived.LastUpdatedAt, wantArchivedAt)
+ }
+
+ err = database.View(ctx, func(txn *db.Txn) error {
+ if exists, err := txn.Exists(db.KeyDirActive(hash)); err != nil {
+ return err
+ } else if exists {
+ t.Fatalf("expected dir active key to be removed")
+ }
+ if exists, err := txn.Exists(db.KeyIdxActive(started.SID)); err != nil {
+ return err
+ } else if exists {
+ t.Fatalf("expected idx active key to be removed")
+ }
+
+ tsHex := db.Uint64Hex(uint64(wantArchivedAt.UnixNano()))
+ idxVal, err := txn.Get(db.KeyIdxArchived(tsHex, started.SID))
+ if err != nil {
+ return err
+ }
+ if string(idxVal) != hash {
+ t.Fatalf("idx archived value mismatch: got %s want %s", string(idxVal), hash)
+ }
+
+ dirArchivedKey := db.KeyDirArchived(hash, tsHex, started.SID)
+ if exists, err := txn.Exists(dirArchivedKey); err != nil {
+ return err
+ } else if !exists {
+ t.Fatalf("expected dir archived key to exist")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("validate archival keys: %v", err)
+ }
+
+ repeat, err := store.Archive(ctx, started.SID)
+ if err != nil {
+ t.Fatalf("Archive second time: %v", err)
+ }
+ if repeat.State != session.StateArchived {
+ t.Fatalf("expected archived state on second archive")
+ }
+
+ if _, found, err := store.ActiveByPath(ctx, workdir); err != nil {
+ t.Fatalf("post-archive ActiveByPath error: %v", err)
+ } else if found {
+ t.Fatalf("ActiveByPath should not find archived session")
+ }
+}