diff --git a/internal/session/session.go b/internal/session/session.go index 79167ef..4fada08 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "time" "github.com/ryanfowler/fetch/internal/fileutil" @@ -169,6 +170,7 @@ func (j *sessionJar) SetCookies(u *url.URL, cookies []*http.Cookie) { j.jar.SetCookies(u, cookies) // Record cookies into the session. + now := time.Now() for _, c := range cookies { sc := SessionCookie{ Name: c.Name, @@ -193,11 +195,18 @@ func (j *sessionJar) SetCookies(u *url.URL, cookies []*http.Cookie) { case http.SameSiteNoneMode: sc.SameSite = "none" } + sc.Domain = normalizeCookieDomain(sc.Domain) + sc.Path = normalizeCookiePath(sc.Path) + + if isDeletionCookie(c, now) { + j.session.removeCookie(sc.Name, sc.Domain, sc.Path) + continue + } // Update existing cookie or append new one. found := false for i, existing := range j.session.Cookies { - if existing.Name == sc.Name && existing.Domain == sc.Domain && existing.Path == sc.Path { + if cookieKeyMatches(existing, sc.Name, sc.Domain, sc.Path) { j.session.Cookies[i] = sc found = true break @@ -213,6 +222,38 @@ func (j *sessionJar) Cookies(u *url.URL) []*http.Cookie { return j.jar.Cookies(u) } +func (s *Session) removeCookie(name, domain, path string) { + filtered := s.Cookies[:0] + for _, existing := range s.Cookies { + if cookieKeyMatches(existing, name, domain, path) { + continue + } + filtered = append(filtered, existing) + } + s.Cookies = filtered +} + +func cookieKeyMatches(c SessionCookie, name, domain, path string) bool { + return c.Name == name && + normalizeCookieDomain(c.Domain) == domain && + normalizeCookiePath(c.Path) == path +} + +func normalizeCookieDomain(domain string) string { + return strings.TrimPrefix(strings.ToLower(domain), ".") +} + +func normalizeCookiePath(path string) string { + if path == "" { + return "/" + } + return path +} + +func isDeletionCookie(c *http.Cookie, now time.Time) bool { + return c.MaxAge < 0 || (!c.Expires.IsZero() && !c.Expires.After(now)) +} + func getSessionsDir() (string, error) { // Allow override for testing. if dir := os.Getenv("FETCH_INTERNAL_SESSIONS_DIR"); dir != "" { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index f790f2e..e0ea595 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -256,6 +256,53 @@ func TestSessionJarUpdatesExisting(t *testing.T) { } } +func TestSessionJarDeletedCookieNotPersisted(t *testing.T) { + dir := t.TempDir() + t.Setenv("FETCH_INTERNAL_SESSIONS_DIR", dir) + + sess, err := Load("delete-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + jar := sess.Jar() + u, _ := url.Parse("https://example.com/") + + jar.SetCookies(u, []*http.Cookie{ + {Name: "token", Value: "live"}, + }) + if err := sess.Save(); err != nil { + t.Fatalf("initial save failed: %v", err) + } + + sess, err = Load("delete-test") + if err != nil { + t.Fatalf("reload failed: %v", err) + } + if len(sess.Cookies) != 1 { + t.Fatalf("expected 1 cookie after reload, got %d", len(sess.Cookies)) + } + + jar = sess.Jar() + jar.SetCookies(u, []*http.Cookie{ + {Name: "token", MaxAge: -1}, + }) + if len(sess.Cookies) != 0 { + t.Fatalf("expected deleted cookie to be removed from session, got %+v", sess.Cookies) + } + if err := sess.Save(); err != nil { + t.Fatalf("save after deletion failed: %v", err) + } + + sess, err = Load("delete-test") + if err != nil { + t.Fatalf("reload after deletion failed: %v", err) + } + if len(sess.Cookies) != 0 { + t.Fatalf("expected deleted cookie to stay removed after reload, got %+v", sess.Cookies) + } +} + func TestCorruptedSessionFile(t *testing.T) { dir := t.TempDir() t.Setenv("FETCH_INTERNAL_SESSIONS_DIR", dir)