Address Gadfly review on #7: garden cap, dim validation, shared errors
Build image / build-and-push (push) Successful in 5s

Fixes from the PR #26 adversarial review (graded 18 real / 0 false positive).

Correctness / security
- maxGardenCM fixed to 10_000 (100 m), matching its comment — it was
  100_000 cm (1 km), 10x too lax (5 models flagged this).
- Dimension validation now rejects NaN/Inf (which slip past naive
  comparisons) and subnormal-tiny positives, via a finite [1cm, 100m]
  check. Name (200) and notes (10_000) are length-capped so untrusted
  input can't balloon storage.
- Update version binding is `required,min=1`, so a negative/zero version
  is a 400, not a 409.

Maintainability / performance
- One unified writeServiceError (new errors.go) maps every auth + resource
  sentinel; writeResourceError removed. writeVersionConflict and
  parseIDParam moved to errors.go (shared, not in the gardens feature file).
- Request structs share an embedded gardenFields (one toInput).
- CreateGarden uses INSERT ... RETURNING (one round-trip).
- ListGardensForOwner has a defensive LIMIT (pagination is post-v1).

Tests: name/notes length, NaN/Inf/subnormal dims, dimension-at-cap valid,
negative/zero version -> 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 18:39:27 -04:00
co-authored by Claude Opus 4.8
parent f39ed52868
commit 3fbefa4e05
7 changed files with 165 additions and 120 deletions
+27 -10
View File
@@ -2,17 +2,22 @@ package service
import (
"context"
"math"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Garden sizing bounds. A new garden with no dimensions defaults to a 10 m
// square; the max is a generous sanity cap (100 m) that also guards against
// absurd or overflow-y values.
// Garden sizing and field bounds. A new garden with no dimensions defaults to a
// 10 m square; dimensions must be finite and within [1 cm, 100 m] — a generous
// sanity range that also rejects NaN/Inf and absurd values. Name/notes are
// length-capped so untrusted input can't balloon storage.
const (
defaultGardenCM = 1000
maxGardenCM = 100_000
defaultGardenCM = 1000 // 10 m
minGardenCM = 1 // 1 cm
maxGardenCM = 10_000 // 100 m
maxGardenNameLen = 200
maxGardenNotesLen = 10_000
)
// gardenRole ranks a user's access to a garden. Higher includes lower
@@ -118,12 +123,17 @@ func (s *Service) DeleteGarden(ctx context.Context, actorID, gardenID int64) err
// filled in (create); without it, every field must be supplied (update).
func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
if name == "" || len(name) > maxGardenNameLen {
return nil, domain.ErrInvalidInput
}
notes := strings.TrimSpace(in.Notes)
if len(notes) > maxGardenNotesLen {
return nil, domain.ErrInvalidInput
}
// 0 means "unset": defaulted on create, rejected on update. A negative value
// is always an explicit error (never silently corrected to the default).
// 0 means "unset": defaulted on create, rejected on update. Anything else
// must be a finite length in range — this also rejects negatives, NaN, Inf,
// and subnormal-tiny values (which are > 0 but below 1 cm).
width, height := in.WidthCM, in.HeightCM
if applyDefaults {
if width == 0 {
@@ -133,7 +143,7 @@ func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error)
height = defaultGardenCM
}
}
if width <= 0 || height <= 0 || width > maxGardenCM || height > maxGardenCM {
if !validDimensionCM(width) || !validDimensionCM(height) {
return nil, domain.ErrInvalidInput
}
@@ -150,6 +160,13 @@ func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error)
WidthCM: width,
HeightCM: height,
UnitPref: unit,
Notes: strings.TrimSpace(in.Notes),
Notes: notes,
}, nil
}
// validDimensionCM reports whether v is a finite length within the allowed
// garden range. Guards against NaN/Inf (which slip past naive < / > comparisons)
// and subnormal-tiny positives.
func validDimensionCM(v float64) bool {
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= minGardenCM && v <= maxGardenCM
}
+17 -5
View File
@@ -3,6 +3,8 @@ package service
import (
"context"
"errors"
"math"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -45,17 +47,27 @@ func TestCreateGardenValidation(t *testing.T) {
owner := seedUser(t, s, "[email protected]")
cases := []GardenInput{
{Name: " "}, // blank name
{Name: "X", UnitPref: "furlongs"}, // bad unit
{Name: "X", WidthCM: -5}, // non-positive dim (defaults only fill 0)
{Name: "X", WidthCM: maxGardenCM + 1}, // over the cap
{Name: "X", HeightCM: maxGardenCM * 10}, // over the cap
{Name: " "}, // blank name
{Name: strings.Repeat("a", maxGardenNameLen+1)}, // name too long
{Name: "X", Notes: strings.Repeat("b", maxGardenNotesLen+1)}, // notes too long
{Name: "X", UnitPref: "furlongs"}, // bad unit
{Name: "X", WidthCM: -5}, // negative (defaults only fill exact 0)
{Name: "X", WidthCM: maxGardenCM + 1}, // over the cap
{Name: "X", HeightCM: maxGardenCM * 10}, // over the cap
{Name: "X", WidthCM: math.NaN()}, // NaN slips past naive < / >
{Name: "X", HeightCM: math.Inf(1)}, // +Inf
{Name: "X", WidthCM: math.SmallestNonzeroFloat64}, // subnormal, > 0 but < 1 cm
}
for i, in := range cases {
if _, err := s.CreateGarden(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("case %d: err = %v, want ErrInvalidInput", i, err)
}
}
// A dimension exactly at the cap is valid.
if _, err := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Big", WidthCM: maxGardenCM, HeightCM: maxGardenCM}); err != nil {
t.Errorf("dimension at cap should be valid: %v", err)
}
}
func TestListGardensOwnedOnly(t *testing.T) {