Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (

require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
github.com/arran4/golang-ical v0.3.5 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A=
github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
Expand Down Expand Up @@ -146,6 +148,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/adiantum v1.1.1 h1:4fp6gTxWCqpEbLy40ExiYDDED3oUNWx5cTqBCtPdZqA=
Expand Down
1 change: 1 addition & 0 deletions internal/adapters/nylas/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ type MockClient struct {
GetMessagesFunc func(ctx context.Context, grantID string, limit int) ([]domain.Message, error)
GetMessagesWithParamsFunc func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error)
GetMessageFunc func(ctx context.Context, grantID, messageID string) (*domain.Message, error)
GetMessageWithFieldsFunc func(ctx context.Context, grantID, messageID, fields string) (*domain.Message, error)
SendMessageFunc func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error)
SendRawMessageFunc func(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error)
GetSignaturesFunc func(ctx context.Context, grantID string) ([]domain.Signature, error)
Expand Down
7 changes: 7 additions & 0 deletions internal/adapters/nylas/mock_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ func (m *MockClient) GetMessage(ctx context.Context, grantID, messageID string)
}

// GetMessageWithFields retrieves a message with optional field selection.
// Tests can pin a specific response by setting GetMessageWithFieldsFunc;
// otherwise the mock falls back to GetMessage and synthesises a default
// raw MIME body so callers exercising the raw_mime path don't get nil.
func (m *MockClient) GetMessageWithFields(ctx context.Context, grantID, messageID string, fields string) (*domain.Message, error) {
if m.GetMessageWithFieldsFunc != nil {
return m.GetMessageWithFieldsFunc(ctx, grantID, messageID, fields)
}

msg, err := m.GetMessage(ctx, grantID, messageID)
if err != nil {
return nil, err
Expand Down
24 changes: 24 additions & 0 deletions internal/air/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,29 @@ func OpenSharedDB(basePath, filename string) (*sql.DB, error) {
_, _ = db.Exec("PRAGMA journal_mode=WAL")
_, _ = db.Exec("PRAGMA synchronous=NORMAL")

// Tighten file permissions to 0600 (defense in depth — the parent
// directory is already 0700). Errors are non-fatal: missing perms or
// non-Unix filesystems shouldn't abort startup.
restrictDBFileMode(dbPath)

return db, nil
}

// restrictDBFileMode chmods the SQLite database file (and its WAL/SHM
// sidecars when they exist) to 0600. Best-effort — no-op on systems
// where chmod has no meaning, and errors are deliberately ignored.
func restrictDBFileMode(dbPath string) {
if _, err := os.Stat(dbPath); err == nil {
_ = os.Chmod(dbPath, 0600)
}
for _, suffix := range []string{"-wal", "-shm"} {
p := dbPath + suffix
if _, err := os.Stat(p); err == nil {
_ = os.Chmod(p, 0600)
}
}
}

// sanitizeEmail converts email to a safe filename.
// Example: user@example.com -> user@example.com.db
func sanitizeEmail(email string) string {
Expand Down Expand Up @@ -152,6 +172,10 @@ func (m *Manager) GetDB(email string) (*sql.DB, error) {
return nil, fmt.Errorf("init schema for %s: %w", email, err)
}

// Tighten file mode to 0600 once the file definitely exists (defense
// in depth atop the 0700 parent directory).
restrictDBFileMode(dbPath)

m.dbs[email] = db
return db, nil
}
Expand Down
121 changes: 121 additions & 0 deletions internal/air/cache/cache_filemode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package cache

import (
"os"
"path/filepath"
"runtime"
"testing"
)

// TestNewManager_SetsCacheDirMode confirms the cache directory is 0700.
func TestNewManager_SetsCacheDirMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file mode semantics differ on Windows")
}

dir := t.TempDir()
cacheDir := filepath.Join(dir, "air-cache")

if _, err := NewManager(Config{BasePath: cacheDir}); err != nil {
t.Fatalf("NewManager: %v", err)
}

info, err := os.Stat(cacheDir)
if err != nil {
t.Fatalf("stat cache dir: %v", err)
}
if mode := info.Mode().Perm(); mode != 0700 {
t.Errorf("cache dir mode: want 0700, got %o", mode)
}
}

// TestGetDB_RestrictsFileMode confirms the per-account .db file is 0600
// after Manager.GetDB initializes the schema. This is defense-in-depth on
// top of the 0700 directory mode — a permissive umask should not leave the
// SQLite file world-readable.
func TestGetDB_RestrictsFileMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file mode semantics differ on Windows")
}

dir := t.TempDir()
mgr, err := NewManager(Config{BasePath: dir})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
t.Cleanup(func() { _ = mgr.Close() })

if _, err := mgr.GetDB("user@example.com"); err != nil {
t.Fatalf("GetDB: %v", err)
}

dbPath := mgr.DBPath("user@example.com")
info, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("stat db file: %v", err)
}
if mode := info.Mode().Perm(); mode != 0600 {
t.Errorf("db file mode: want 0600, got %o (path=%s)", mode, dbPath)
}
}

// TestRestrictDBFileMode_HandlesMissingFiles ensures the helper silently
// no-ops on missing sidecar files and never panics on bad paths.
func TestRestrictDBFileMode_HandlesMissingFiles(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file mode semantics differ on Windows")
}

dir := t.TempDir()
mainPath := filepath.Join(dir, "exists.db")
if err := os.WriteFile(mainPath, []byte("dummy"), 0644); err != nil {
t.Fatalf("seed main file: %v", err)
}
// -wal and -shm intentionally absent.

restrictDBFileMode(mainPath)

info, err := os.Stat(mainPath)
if err != nil {
t.Fatalf("stat after restrict: %v", err)
}
if mode := info.Mode().Perm(); mode != 0600 {
t.Errorf("file mode after restrict: want 0600, got %o", mode)
}

// Non-existent path: must not panic, must not create files.
restrictDBFileMode(filepath.Join(dir, "does-not-exist.db"))
if _, err := os.Stat(filepath.Join(dir, "does-not-exist.db")); !os.IsNotExist(err) {
t.Errorf("restrict should not create files; stat err=%v", err)
}
}

// TestOpenSharedDB_RestrictsFileMode confirms the shared photos.db file
// also gets the tightened mode.
func TestOpenSharedDB_RestrictsFileMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file mode semantics differ on Windows")
}

dir := t.TempDir()
db, err := OpenSharedDB(dir, "photos.db")
if err != nil {
t.Fatalf("OpenSharedDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })

// Force an actual file by running a trivial DDL.
if _, err := db.Exec("CREATE TABLE IF NOT EXISTS t (id INTEGER)"); err != nil {
t.Fatalf("create table: %v", err)
}
// Re-apply restrict in case the table creation only just produced the file.
restrictDBFileMode(filepath.Join(dir, "photos.db"))

info, err := os.Stat(filepath.Join(dir, "photos.db"))
if err != nil {
t.Fatalf("stat shared db: %v", err)
}
if mode := info.Mode().Perm(); mode != 0600 {
t.Errorf("shared db mode: want 0600, got %o", mode)
}
}
10 changes: 10 additions & 0 deletions internal/air/cache/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ func (m *EncryptedManager) GetDB(email string) (*sql.DB, error) {
return nil, fmt.Errorf("init schema for %s: %w", email, err)
}

// Tighten file mode to 0600 — the file holds encrypted email/calendar
// data and should never be world-readable even when umask is permissive.
restrictDBFileMode(dbPath)

m.dbs[email] = db
return db, nil
}
Expand Down Expand Up @@ -291,6 +295,9 @@ func (m *EncryptedManager) MigrateToEncrypted(email string) error {
_ = os.Remove(backupPath + "-wal")
_ = os.Remove(backupPath + "-shm")

// Lock down file mode on the freshly migrated database.
restrictDBFileMode(dbPath)

return nil
}

Expand Down Expand Up @@ -364,6 +371,9 @@ func (m *EncryptedManager) MigrateToUnencrypted(email string) error {
_ = deleteKeyFunc(email)
delete(m.keys, email)

// Lock down file mode on the freshly migrated database.
restrictDBFileMode(dbPath)

return nil
}

Expand Down
44 changes: 43 additions & 1 deletion internal/air/cache/photos.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,46 @@ package cache

import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

// DefaultPhotoTTL is the default time-to-live for cached photos (30 days).
const DefaultPhotoTTL = 30 * 24 * time.Hour

// errInvalidContactID is returned when the caller hands the photo store an
// identifier that would be unsafe to compose into a filesystem path.
var errInvalidContactID = errors.New("invalid contact ID for photo cache")

// validateContactID rejects identifiers that would let a malicious or
// malformed upstream contact escape the photo cache directory. Photo files
// are stored at filepath.Join(basePath, contactID), so anything containing
// a path separator, "..", control bytes, or NUL is unsafe. We also bound
// the length so a hostile API response can't push us into "name too long"
// errors at the filesystem layer.
func validateContactID(id string) error {
if id == "" || len(id) > 128 {
return errInvalidContactID
}
if strings.ContainsAny(id, "/\\\x00") {
return errInvalidContactID
}
if id == "." || id == ".." || strings.Contains(id, "..") {
return errInvalidContactID
}
for i := 0; i < len(id); i++ {
c := id[i]
if c < 0x20 || c == 0x7f {
return errInvalidContactID
}
}
return nil
}

// CachedPhoto represents a contact photo stored in the cache.
type CachedPhoto struct {
ContactID string `json:"contact_id"`
Expand Down Expand Up @@ -67,7 +98,9 @@ func NewPhotoStore(db *sql.DB, basePath string, ttl time.Duration) (*PhotoStore,

// Put stores a contact photo.
func (s *PhotoStore) Put(contactID, contentType string, data []byte) error {
// Write photo to file
if err := validateContactID(contactID); err != nil {
return err
}
localPath := filepath.Join(s.basePath, contactID)
if err := os.WriteFile(localPath, data, 0600); err != nil {
return fmt.Errorf("write photo file: %w", err)
Expand Down Expand Up @@ -95,6 +128,9 @@ func (s *PhotoStore) Put(contactID, contentType string, data []byte) error {
// Get retrieves a cached photo if it exists and is not expired.
// Returns nil, nil if the photo is not cached or expired.
func (s *PhotoStore) Get(contactID string) ([]byte, string, error) {
if err := validateContactID(contactID); err != nil {
return nil, "", err
}
row := s.db.QueryRow(`
SELECT content_type, size, local_path, cached_at
FROM photos WHERE contact_id = ?
Expand Down Expand Up @@ -136,6 +172,9 @@ func (s *PhotoStore) Get(contactID string) ([]byte, string, error) {

// IsValid checks if a cached photo exists and is not expired.
func (s *PhotoStore) IsValid(contactID string) bool {
if err := validateContactID(contactID); err != nil {
return false
}
var cachedAtUnix int64
err := s.db.QueryRow("SELECT cached_at FROM photos WHERE contact_id = ?", contactID).Scan(&cachedAtUnix)
if err != nil {
Expand All @@ -148,6 +187,9 @@ func (s *PhotoStore) IsValid(contactID string) bool {

// Delete removes a cached photo.
func (s *PhotoStore) Delete(contactID string) error {
if err := validateContactID(contactID); err != nil {
return err
}
// Get local path first
var localPath string
err := s.db.QueryRow("SELECT local_path FROM photos WHERE contact_id = ?", contactID).Scan(&localPath)
Expand Down
Loading
Loading