Skip to content

Commit 2c70750

Browse files
cpcloudcursoragentclaude
committed
fix(data): address medium-severity audit findings
Three fixes from the code audit: 1. parseCents now rejects negative values -- all money fields represent costs, fees, or budgets where negatives don't make sense. Inputs like "-$5.00", "$-100", and "--$5" are all rejected with ErrInvalidMoney. 2. requireParentAlive now distinguishes soft-deleted parents from truly missing parents. Callers show "X is deleted -- restore it first" vs "X no longer exists" as appropriate. Introduced ErrParentDeleted / ErrParentNotFound sentinel errors. 3. SQLite Open() now sets journal_mode=WAL, synchronous=NORMAL, and busy_timeout=5000 for better durability and concurrency behavior. closes #186 Co-authored-by: Cursor <cursoragent@cursor.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 33ebe6e commit 2c70750

File tree

4 files changed

+96
-20
lines changed

4 files changed

+96
-20
lines changed

internal/app/forms.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package app
55

66
import (
7+
"errors"
78
"fmt"
89
"strconv"
910
"strings"
@@ -1512,10 +1513,17 @@ func optionalDate(label string) func(string) error {
15121513
}
15131514
}
15141515

1516+
func moneyError(label string, err error) error {
1517+
if errors.Is(err, data.ErrNegativeMoney) {
1518+
return fmt.Errorf("%s must be a positive amount", label)
1519+
}
1520+
return fmt.Errorf("%s should look like 1250.00", label)
1521+
}
1522+
15151523
func optionalMoney(label string) func(string) error {
15161524
return func(input string) error {
15171525
if _, err := data.ParseOptionalCents(input); err != nil {
1518-
return fmt.Errorf("%s should look like 1250.00", label)
1526+
return moneyError(label, err)
15191527
}
15201528
return nil
15211529
}
@@ -1524,7 +1532,7 @@ func optionalMoney(label string) func(string) error {
15241532
func requiredMoney(label string) func(string) error {
15251533
return func(input string) error {
15261534
if _, err := data.ParseRequiredCents(input); err != nil {
1527-
return fmt.Errorf("%s should look like 1250.00", label)
1535+
return moneyError(label, err)
15281536
}
15291537
return nil
15301538
}

internal/data/store.go

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,19 @@ func Open(path string) (*Store, error) {
3333
if err != nil {
3434
return nil, fmt.Errorf("open db: %w", err)
3535
}
36-
if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
37-
return nil, fmt.Errorf("enable foreign keys: %w", err)
36+
pragmas := []struct {
37+
sql string
38+
desc string
39+
}{
40+
{"PRAGMA foreign_keys = ON", "enable foreign keys"},
41+
{"PRAGMA journal_mode = WAL", "enable WAL mode"},
42+
{"PRAGMA synchronous = NORMAL", "set synchronous mode"},
43+
{"PRAGMA busy_timeout = 5000", "set busy timeout"},
44+
}
45+
for _, p := range pragmas {
46+
if err := db.Exec(p.sql).Error; err != nil {
47+
return nil, fmt.Errorf("%s: %w", p.desc, err)
48+
}
3849
}
3950
return &Store{db: db}, nil
4051
}
@@ -710,11 +721,11 @@ func (s *Store) RestoreServiceLog(id uint) error {
710721
return err
711722
}
712723
if err := s.requireParentAlive(&MaintenanceItem{}, entry.MaintenanceItemID); err != nil {
713-
return fmt.Errorf("maintenance item is deleted -- restore it first")
724+
return parentRestoreError("maintenance item", err)
714725
}
715726
if entry.VendorID != nil {
716727
if err := s.requireParentAlive(&Vendor{}, *entry.VendorID); err != nil {
717-
return fmt.Errorf("vendor is deleted -- restore it first")
728+
return parentRestoreError("vendor", err)
718729
}
719730
}
720731
return s.restoreEntity(&ServiceLogEntry{}, DeletionEntityServiceLog, id)
@@ -784,7 +795,7 @@ func (s *Store) RestoreProject(id uint) error {
784795
}
785796
if project.PreferredVendorID != nil {
786797
if err := s.requireParentAlive(&Vendor{}, *project.PreferredVendorID); err != nil {
787-
return fmt.Errorf("preferred vendor is deleted -- restore it first")
798+
return parentRestoreError("preferred vendor", err)
788799
}
789800
}
790801
return s.restoreEntity(&Project{}, DeletionEntityProject, id)
@@ -796,10 +807,10 @@ func (s *Store) RestoreQuote(id uint) error {
796807
return err
797808
}
798809
if err := s.requireParentAlive(&Project{}, quote.ProjectID); err != nil {
799-
return fmt.Errorf("project is deleted -- restore it first")
810+
return parentRestoreError("project", err)
800811
}
801812
if err := s.requireParentAlive(&Vendor{}, quote.VendorID); err != nil {
802-
return fmt.Errorf("vendor is deleted -- restore it first")
813+
return parentRestoreError("vendor", err)
803814
}
804815
return s.restoreEntity(&Quote{}, DeletionEntityQuote, id)
805816
}
@@ -811,7 +822,7 @@ func (s *Store) RestoreMaintenance(id uint) error {
811822
}
812823
if item.ApplianceID != nil {
813824
if err := s.requireParentAlive(&Appliance{}, *item.ApplianceID); err != nil {
814-
return fmt.Errorf("appliance is deleted -- restore it first or clear the link")
825+
return parentRestoreError("appliance", err)
815826
}
816827
}
817828
return s.restoreEntity(&MaintenanceItem{}, DeletionEntityMaintenance, id)
@@ -821,11 +832,39 @@ func (s *Store) RestoreAppliance(id uint) error {
821832
return s.restoreEntity(&Appliance{}, DeletionEntityAppliance, id)
822833
}
823834

824-
// requireParentAlive returns an error if the given parent record is
825-
// soft-deleted (or doesn't exist). Uses the default GORM scope which
826-
// excludes soft-deleted rows.
835+
var (
836+
// ErrParentDeleted indicates the parent record exists but is soft-deleted.
837+
ErrParentDeleted = errors.New("parent record is deleted")
838+
// ErrParentNotFound indicates the parent record doesn't exist at all.
839+
ErrParentNotFound = errors.New("parent record not found")
840+
)
841+
842+
// requireParentAlive returns ErrParentDeleted if the parent record is
843+
// soft-deleted, or ErrParentNotFound if it doesn't exist at all. Returns nil
844+
// when the parent is alive.
827845
func (s *Store) requireParentAlive(model any, id uint) error {
828-
return s.db.First(model, id).Error
846+
err := s.db.First(model, id).Error
847+
if err == nil {
848+
return nil
849+
}
850+
if !errors.Is(err, gorm.ErrRecordNotFound) {
851+
return err
852+
}
853+
// Distinguish soft-deleted from truly missing.
854+
if err := s.db.Unscoped().First(model, id).Error; err != nil {
855+
return ErrParentNotFound
856+
}
857+
return ErrParentDeleted
858+
}
859+
860+
// parentRestoreError returns a user-facing error message for a failed parent
861+
// alive check, distinguishing soft-deleted parents (restorable) from missing
862+
// parents (permanently gone).
863+
func parentRestoreError(entity string, err error) error {
864+
if errors.Is(err, ErrParentNotFound) {
865+
return fmt.Errorf("%s no longer exists", entity)
866+
}
867+
return fmt.Errorf("%s is deleted -- restore it first", entity)
829868
}
830869

831870
// countDependents counts non-deleted rows in model where fkColumn equals id.

internal/data/validation.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@ import (
1717
const DateLayout = "2006-01-02"
1818

1919
var (
20-
ErrInvalidMoney = errors.New("invalid money value")
21-
ErrInvalidDate = errors.New("invalid date value")
22-
ErrInvalidInt = errors.New("invalid integer value")
23-
ErrInvalidFloat = errors.New("invalid decimal value")
20+
ErrInvalidMoney = errors.New("invalid money value")
21+
ErrNegativeMoney = errors.New("negative money value")
22+
ErrInvalidDate = errors.New("invalid date value")
23+
ErrInvalidInt = errors.New("invalid integer value")
24+
ErrInvalidFloat = errors.New("invalid decimal value")
2425
)
2526

2627
func ParseRequiredCents(input string) (int64, error) {
2728
cents, err := parseCents(strings.TrimSpace(input))
2829
if err != nil {
29-
return 0, ErrInvalidMoney
30+
return 0, err
3031
}
3132
return cents, nil
3233
}
@@ -38,7 +39,7 @@ func ParseOptionalCents(input string) (*int64, error) {
3839
}
3940
cents, err := parseCents(trimmed)
4041
if err != nil {
41-
return nil, ErrInvalidMoney
42+
return nil, err
4243
}
4344
return &cents, nil
4445
}
@@ -182,6 +183,10 @@ func ComputeNextDue(last *time.Time, intervalMonths int) *time.Time {
182183

183184
func parseCents(input string) (int64, error) {
184185
clean := strings.ReplaceAll(input, ",", "")
186+
// Reject negative values -- all money fields are costs/fees/budgets.
187+
if strings.HasPrefix(clean, "-") {
188+
return 0, ErrNegativeMoney
189+
}
185190
clean = strings.TrimPrefix(clean, "$")
186191
if clean == "" {
187192
return 0, ErrInvalidMoney

internal/data/validation_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,30 @@ func TestFormatCentsNegative(t *testing.T) {
9191
assert.Equal(t, "-$5.00", FormatCents(-500))
9292
}
9393

94+
func TestParseCentsRejectsNegative(t *testing.T) {
95+
// Leading-negative inputs return ErrNegativeMoney specifically.
96+
for _, input := range []string{"-$5.00", "-5.00", "-$1,234.56"} {
97+
_, err := ParseRequiredCents(input)
98+
assert.ErrorIs(t, err, ErrNegativeMoney, "input=%q", input)
99+
}
100+
// Malformed negatives (sign in wrong position, bare dash) return ErrInvalidMoney.
101+
for _, input := range []string{"$-100", "--$5", "-", "-$"} {
102+
_, err := ParseRequiredCents(input)
103+
assert.Error(t, err, "input=%q should be rejected", input)
104+
}
105+
}
106+
107+
func TestParseCentsFormatRoundtrip(t *testing.T) {
108+
// FormatCents output should parse back to the same value.
109+
values := []int64{0, 1, 99, 100, 123456}
110+
for _, cents := range values {
111+
formatted := FormatCents(cents)
112+
parsed, err := ParseRequiredCents(formatted)
113+
require.NoError(t, err, "roundtrip failed for %d (formatted=%q)", cents, formatted)
114+
assert.Equal(t, cents, parsed, "roundtrip mismatch for %d (formatted=%q)", cents, formatted)
115+
}
116+
}
117+
94118
func TestFormatCentsZero(t *testing.T) {
95119
assert.Equal(t, "$0.00", FormatCents(0))
96120
}

0 commit comments

Comments
 (0)