Skip to content

feat(sinatra): cookie session store (enable :sessions / session helper) - #169

Merged
tannevaled merged 2 commits into
mainfrom
feat/sinatra-session-store
Jul 6, 2026
Merged

tannevaled merged 2 commits into
mainfrom
feat/sinatra-session-store

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Implements the Sinatra session-store seam PR #157 deferred (templating landed via #162/#166): enable :sessions + a working session helper persisted across requests via a signed cookie, matching MRI Sinatra's Rack::Session::Cookie.

Mechanism (load / save / sign)

  • Load (per dispatch, sinatra_session.go): parse the request's rack.session cookie (Rack::Utils.parse_cookies via go-ruby-rack), split "<data>--<digest>", verify HMAC-SHA1 over data with session_secret (constant-time hmac.Equal), then base64.StdDecode → Marshal.load → Ruby Hash. A missing / malformed / tampered cookie (bad HMAC) yields a fresh empty session.
  • session helper: returns the live mutable Hash, so session[:k]=v, session[:k]||=… and session.clear persist for the request. Falls back to the old rack.session env seam when sessions are disabled (backward compatible).
  • Save: after the handler runs, re-serialize + re-sign the current hash and append a HttpOnly, path=/ Set-Cookie (Rack commits every response).
  • Secret: set :session_secret, "…" sets the key; without one a per-VM random default is generated once (like MRI's warning path).

Serialization reuses the already-bound go-ruby-marshal engine (toMarshalValue/fromMarshalValue), and base64/HMAC use Go stdlib.

Byte-exact vs functional

  • Byte-exact to MRI given the same session_secret: the payload base64(Marshal.dump(session)) and the HMAC-SHA1 hex digest are byte-identical to Rack::Session::Cookie's default Base64::Marshal coder + SHA1. Proven by TestSinatraSessionCookieByteExact: for {count: 1} / secret "secret" the emitted cookie is exactly rack.session=BAh7BjoKY291bnRpBg%3D%3D--41a93a4ae53bfb87c6064bd9a5f48f28e70c844f; path=/; httponly (constants computed from Marshal.dump({count:1}) and OpenSSL::HMAC.hexdigest).
  • Functional-only (not byte-reproducible across an independent MRI process): the cookie string when the secret is auto-generated (each process picks its own), and any extra keys a full Rack::Protection stack would inject (session_id/csrf) — out of scope.

Through-binary proof

scripts/conformance/webapp/apps/stage5_sinatra_session.rb drives its own request sequence through the rbgo #call adapter, threading each Set-Cookie → Cookie: /inc increments session[:count] (1→2), /read reads it on a different route (persists), a tampered cookie resets the counter to 1, and session.clear empties it. TestSinatraSessionStore asserts the full transcript (an MRI-portable functional golden). Internal TestSinatraSession{RoundTrip,TamperRejected,Clear} cover the same through the DSL.

Verification

  • GOWORK=off go build/vet/test green; conformance webapp suite green (stages 1–5).
  • 100% coverage on all new/changed binding code (sinatra_session.go funcs, sinatraCall/buildSinatraApp/sinatraMergedSettings), including the crypto/rand failure fallback (via a sinatraRandRead seam) and every decoder failure arm.
  • No prelude touched (pure-Go binding).

Deferred surface

Session expiry (expire_after / max-age), alternate stores (Rack::Session::Pool/memcache/redis), and session_id/CSRF injection are follow-ups; this ships the default cookie store.

🤖 Generated with Claude Code

tannevaled and others added 2 commits July 6, 2026 17:10
Implement the Sinatra session seam PR #157 deferred: `enable :sessions`
plus a working `session` helper backed by a signed cookie, matching MRI
Sinatra's Rack::Session::Cookie semantics.

On dispatch the session is loaded from the request's "rack.session" cookie
(base64(Marshal.dump)--HMAC-SHA1(session_secret)); a missing, malformed or
tampered cookie yields a fresh empty session. The `session` helper returns
the live Hash so session[:k]=v and session.clear persist, and after the
handler runs the (possibly mutated) session is committed back into a signed,
HttpOnly, path=/ Set-Cookie. `set :session_secret, "…"` sets the key; without
one a per-VM random default is used (like MRI's warning path).

Serialization reuses the bound go-ruby-marshal engine, so the payload is
byte-identical to MRI's default Base64::Marshal coder; base64 + HMAC-SHA1
match Rack's, making the cookie byte-exact to Rack::Session::Cookie given the
same secret (proven by TestSinatraSessionCookieByteExact). The through-binary
proof is the functional round-trip + tamper rejection (webapp stage 5 and the
internal round-trip/tamper/clear tests), which an MRI Sinatra app reproduces
observably.

100% coverage on the new binding code; go build/vet/test + conformance green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… map

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit a97fb79 into main Jul 6, 2026
9 checks passed
@tannevaled
tannevaled deleted the feat/sinatra-session-store branch July 6, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant