Skip to content

Commit 8719bcd

Browse files
committed
Fix daily token budget file permissions and race
- Add internal/flock package with portable advisory file locking (syscall.Flock on Unix, LockFileEx on Windows). - Serialize CheckDailyBudget and DailyTokenUsage with a lock file. - Write the budget file with 0600 instead of 0644 permissions. - Add tests for flock serialization, budget file permissions, and concurrent budget billing.
1 parent eea12a9 commit 8719bcd

7 files changed

Lines changed: 231 additions & 3 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ require (
99
golang.org/x/term v0.43.0
1010
)
1111

12-
require golang.org/x/sys v0.44.0 // indirect
12+
require golang.org/x/sys v0.44.0

internal/flock/flock.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Package flock provides a portable advisory file lock.
2+
//
3+
// Lock opens or creates a lock file and acquires an exclusive lock on it.
4+
// The returned release function must be called to unlock and close the file.
5+
// The lock is advisory: it only serializes callers that also use this package
6+
// (or otherwise cooperate on the same lock file).
7+
package flock
8+
9+
import (
10+
"fmt"
11+
"os"
12+
)
13+
14+
// Lock acquires an exclusive advisory lock on path. It creates the lock file
15+
// with 0600 permissions if it does not exist. The returned release function
16+
// unlocks and closes the lock file; callers should defer it.
17+
func Lock(path string) (func(), error) {
18+
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
19+
if err != nil {
20+
return nil, fmt.Errorf("flock: open: %w", err)
21+
}
22+
if err := lockFile(int(f.Fd())); err != nil {
23+
f.Close()
24+
return nil, fmt.Errorf("flock: lock: %w", err)
25+
}
26+
return func() {
27+
unlockFile(int(f.Fd()))
28+
f.Close()
29+
}, nil
30+
}

internal/flock/flock_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package flock
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strconv"
7+
"sync"
8+
"testing"
9+
)
10+
11+
func TestLock_AcquireAndRelease(t *testing.T) {
12+
dir := t.TempDir()
13+
lockPath := filepath.Join(dir, "test.lock")
14+
15+
release, err := Lock(lockPath)
16+
if err != nil {
17+
t.Fatalf("Lock: %v", err)
18+
}
19+
20+
// Lock file should exist with restricted permissions.
21+
info, err := os.Stat(lockPath)
22+
if err != nil {
23+
t.Fatalf("stat lock file: %v", err)
24+
}
25+
if info.Mode().Perm()&0077 != 0 {
26+
t.Errorf("lock file is world/group accessible: %o", info.Mode().Perm())
27+
}
28+
29+
release()
30+
31+
// After release, the lock file may be left behind; that's fine.
32+
}
33+
34+
func TestLock_SerializesConcurrentWriters(t *testing.T) {
35+
dir := t.TempDir()
36+
counterPath := filepath.Join(dir, "counter")
37+
lockPath := filepath.Join(dir, "counter.lock")
38+
39+
if err := os.WriteFile(counterPath, []byte("0"), 0600); err != nil {
40+
t.Fatalf("write counter: %v", err)
41+
}
42+
43+
var wg sync.WaitGroup
44+
workers := 20
45+
increments := 50
46+
for i := 0; i < workers; i++ {
47+
wg.Add(1)
48+
go func() {
49+
defer wg.Done()
50+
for j := 0; j < increments; j++ {
51+
release, err := Lock(lockPath)
52+
if err != nil {
53+
t.Errorf("Lock: %v", err)
54+
return
55+
}
56+
data, err := os.ReadFile(counterPath)
57+
if err != nil {
58+
release()
59+
t.Errorf("read counter: %v", err)
60+
return
61+
}
62+
n, err := strconv.Atoi(string(data))
63+
if err != nil {
64+
release()
65+
t.Errorf("parse counter: %v", err)
66+
return
67+
}
68+
if err := os.WriteFile(counterPath, []byte(strconv.Itoa(n+1)), 0600); err != nil {
69+
release()
70+
t.Errorf("write counter: %v", err)
71+
return
72+
}
73+
release()
74+
}
75+
}()
76+
}
77+
wg.Wait()
78+
79+
data, err := os.ReadFile(counterPath)
80+
if err != nil {
81+
t.Fatalf("read final counter: %v", err)
82+
}
83+
got, err := strconv.Atoi(string(data))
84+
if err != nil {
85+
t.Fatalf("parse final counter: %v", err)
86+
}
87+
want := workers * increments
88+
if got != want {
89+
t.Errorf("counter = %d, want %d (race detected)", got, want)
90+
}
91+
}

internal/flock/flock_unix.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build !windows
2+
3+
package flock
4+
5+
import "syscall"
6+
7+
func lockFile(fd int) error {
8+
return syscall.Flock(fd, syscall.LOCK_EX)
9+
}
10+
11+
func unlockFile(fd int) error {
12+
return syscall.Flock(fd, syscall.LOCK_UN)
13+
}

internal/flock/flock_windows.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build windows
2+
3+
package flock
4+
5+
import (
6+
"golang.org/x/sys/windows"
7+
)
8+
9+
func lockFile(fd int) error {
10+
h := windows.Handle(fd)
11+
var overlapped windows.Overlapped
12+
return windows.LockFileEx(
13+
h,
14+
windows.LOCKFILE_EXCLUSIVE_LOCK,
15+
0,
16+
1,
17+
0,
18+
&overlapped,
19+
)
20+
}
21+
22+
func unlockFile(fd int) error {
23+
h := windows.Handle(fd)
24+
var overlapped windows.Overlapped
25+
return windows.UnlockFileEx(h, 0, 1, 0, &overlapped)
26+
}

internal/telegram/bot.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"sync"
1717
"time"
1818

19+
"github.com/BackendStack21/odek/internal/flock"
1920
"github.com/BackendStack21/odek/internal/transport"
2021
)
2122

@@ -680,6 +681,9 @@ func budgetFilePath() string {
680681
// adds the given number of tokens, and returns an error if the total
681682
// exceeds the configured DailyTokenBudget. If the budget is zero (unset),
682683
// no check is performed and nil is returned.
684+
//
685+
// The read-modify-write cycle is protected by an advisory file lock so
686+
// concurrent odek processes and goroutines cannot clobber the counter.
683687
func (b *Bot) CheckDailyBudget(tokens int64) error {
684688
if b.DailyTokenBudget <= 0 {
685689
return nil // budget not configured
@@ -696,6 +700,13 @@ func (b *Bot) CheckDailyBudget(tokens int64) error {
696700
return fmt.Errorf("telegram: create budget dir: %w", err)
697701
}
698702

703+
// Serialize read-modify-write across processes.
704+
release, err := flock.Lock(path + ".lock")
705+
if err != nil {
706+
return fmt.Errorf("telegram: lock budget file: %w", err)
707+
}
708+
defer release()
709+
699710
// Read current usage (file may not exist yet — that's fine).
700711
var current int64
701712
data, err := os.ReadFile(path)
@@ -715,8 +726,8 @@ func (b *Bot) CheckDailyBudget(tokens int64) error {
715726
)
716727
}
717728

718-
// Write the updated count.
719-
if err := os.WriteFile(path, []byte(strconv.FormatInt(total, 10)), 0644); err != nil {
729+
// Write the updated count with owner-only permissions.
730+
if err := os.WriteFile(path, []byte(strconv.FormatInt(total, 10)), 0600); err != nil {
720731
return fmt.Errorf("telegram: write budget file: %w", err)
721732
}
722733

@@ -730,6 +741,13 @@ func (b *Bot) DailyTokenUsage() (used int64, limit int64) {
730741
return 0, 0
731742
}
732743
path := budgetFilePath()
744+
745+
release, err := flock.Lock(path + ".lock")
746+
if err != nil {
747+
return 0, b.DailyTokenBudget
748+
}
749+
defer release()
750+
733751
data, err := os.ReadFile(path)
734752
if err == nil {
735753
if parsed, err := strconv.ParseInt(string(data), 10, 64); err == nil {

internal/telegram/bot_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"path/filepath"
1313
"strings"
14+
"sync"
1415
"testing"
1516
"time"
1617
)
@@ -1315,6 +1316,55 @@ func TestBot_CheckDailyBudget_SequentialBillings(t *testing.T) {
13151316
}
13161317
}
13171318

1319+
func TestBot_CheckDailyBudget_FilePermissionsAreRestricted(t *testing.T) {
1320+
tmpDir := t.TempDir()
1321+
t.Setenv("HOME", tmpDir)
1322+
1323+
bot := NewBot("testtoken")
1324+
bot.SetDailyTokenBudget(10_000)
1325+
1326+
if err := bot.CheckDailyBudget(100); err != nil {
1327+
t.Fatalf("CheckDailyBudget: %v", err)
1328+
}
1329+
1330+
date := time.Now().Format("2006-01-02")
1331+
budgetPath := filepath.Join(tmpDir, ".odek", "telegram_token_usage_"+date)
1332+
info, err := os.Stat(budgetPath)
1333+
if err != nil {
1334+
t.Fatalf("stat budget file: %v", err)
1335+
}
1336+
if info.Mode().Perm()&0077 != 0 {
1337+
t.Errorf("budget file is world/group accessible: %o", info.Mode().Perm())
1338+
}
1339+
}
1340+
1341+
func TestBot_CheckDailyBudget_ConcurrentBillingsAreSafe(t *testing.T) {
1342+
tmpDir := t.TempDir()
1343+
t.Setenv("HOME", tmpDir)
1344+
1345+
bot := NewBot("testtoken")
1346+
bot.SetDailyTokenBudget(1_000_000)
1347+
1348+
var wg sync.WaitGroup
1349+
workers := 20
1350+
billEach := 1000
1351+
for i := 0; i < workers; i++ {
1352+
wg.Add(1)
1353+
go func() {
1354+
defer wg.Done()
1355+
if err := bot.CheckDailyBudget(int64(billEach)); err != nil {
1356+
t.Errorf("CheckDailyBudget: %v", err)
1357+
}
1358+
}()
1359+
}
1360+
wg.Wait()
1361+
1362+
used, _ := bot.DailyTokenUsage()
1363+
want := int64(workers * billEach)
1364+
if used != want {
1365+
t.Errorf("DailyTokenUsage = %d, want %d (race detected)", used, want)
1366+
}
1367+
}
13181368
// ---------------------------------------------------------------------------
13191369
// DailyTokenUsage
13201370
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)