From 7c3ac683c69879daf666e909029d9e333f4a3397 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Thu, 29 Jan 2026 12:23:19 -0800 Subject: [PATCH] Add --session flag for persistent cookie storage Implement named sessions that persist cookies across invocations. Sessions are stored as JSON files in the user's cache directory (~/.cache/fetch/sessions/ on Linux, ~/Library/Caches/fetch/sessions/ on macOS). The session name is configurable via --session/-S flag or per-host in the config file. --- AGENTS.md | 1 + docs/advanced-features.md | 56 +++++++ docs/cli-reference.md | 23 +++ docs/configuration.md | 23 +++ integration/integration_test.go | 136 +++++++++++++++++ internal/cli/app.go | 13 ++ internal/client/client.go | 5 + internal/config/config.go | 16 ++ internal/fetch/fetch.go | 34 ++++- internal/session/session.go | 236 +++++++++++++++++++++++++++++ internal/session/session_test.go | 252 +++++++++++++++++++++++++++++++ main.go | 1 + 12 files changed, 795 insertions(+), 1 deletion(-) create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go diff --git a/AGENTS.md b/AGENTS.md index ae4094c..404b73b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ prettier -w . - **internal/image** - Terminal image rendering (Kitty, iTerm2 inline, block-character fallback). - **internal/image** - Multipart form implementation. - **internal/proto** - Protocol buffer compilation and message handling for gRPC support. +- **internal/session** - Named cookie sessions with persistent storage across invocations. - **internal/update** - Check for updates, download from Github, and self-update. ### Request Flow diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 2fbfe32..036fd72 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -375,6 +375,62 @@ tls = 1.3 timeout = 60 ``` +## Cookie Sessions + +### `-S, --session NAME` + +Persistent cookie storage across invocations using named sessions. + +### Basic Usage + +```sh +# First request — server sets cookies, they get saved +fetch --session api https://example.com/login -j '{"user":"me"}' + +# Second request — saved cookies are sent automatically +fetch --session api https://example.com/dashboard +``` + +### Session Isolation + +Different session names maintain separate cookie stores: + +```sh +fetch --session prod https://api.example.com/login +fetch --session staging https://staging.example.com/login +``` + +### Configuration File + +Set session names per-host so you don't need `--session` every time: + +```ini +# Global default session +session = default + +# Per-host session names +[api.example.com] +session = api-prod + +[staging.example.com] +session = api-staging +``` + +### Session File Storage + +Sessions are stored as JSON in the user's cache directory: + +- **Linux**: `~/.cache/fetch/sessions/.json` +- **macOS**: `~/Library/Caches/fetch/sessions/.json` + +### Behavior Details + +- **Expired cookies**: Cookies with an explicit expiry in the past are filtered out on load. +- **Session cookies** (no explicit expiry): Persist across invocations since the session is explicitly named. +- **Cookie domain matching**: Delegated to Go's `net/http/cookiejar`, which implements RFC 6265. +- **Atomic writes**: Session files are written atomically (temp file + rename) to avoid corruption. +- **Name validation**: Only `[a-zA-Z0-9_-]` characters are allowed to prevent path traversal. + ## Debugging Network Issues ### Verbose Output diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5dd3775..0562e23 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -229,6 +229,29 @@ Disable piping output to a pager (`less`). fetch --no-pager example.com ``` +## Sessions + +### `-S, --session NAME` + +Use a named session for persistent cookie storage across invocations. Cookies set by servers are saved to disk and automatically sent on subsequent requests using the same session name. + +Session names must contain only alphanumeric characters, hyphens, and underscores (`[a-zA-Z0-9_-]`). + +```sh +# First request — server sets cookies, they get saved +fetch --session api example.com/login -j '{"user":"me"}' + +# Second request — saved cookies are sent automatically +fetch --session api example.com/dashboard +``` + +Session files are stored in the user's cache directory: + +- **Linux**: `~/.cache/fetch/sessions/.json` +- **macOS**: `~/Library/Caches/fetch/sessions/.json` + +Can also be configured per-host in the [configuration file](configuration.md). + ## Network Options ### `-t, --timeout SECONDS` diff --git a/docs/configuration.md b/docs/configuration.md index 6ff4228..672cc52 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -369,6 +369,29 @@ no-encode = true no-encode = false ``` +### Session Options + +#### `session` + +**Type**: String +**Default**: None + +Set a named session for persistent cookie storage. Cookies set by servers are saved to disk and automatically sent on subsequent requests using the same session name. The name must contain only alphanumeric characters, hyphens, and underscores. + +```ini +# Global default session +session = default + +# Per-host session names +[api.example.com] +session = api-prod + +[staging.example.com] +session = api-staging +``` + +CLI `--session` flag overrides the config value. See [CLI Reference](cli-reference.md) for more details. + ### Request Options #### `header` diff --git a/integration/integration_test.go b/integration/integration_test.go index 4f2ab83..fdedc26 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1304,6 +1304,142 @@ func TestMain(t *testing.T) { assertBufContains(t, res.stderr, "does not exist") }) }) + + t.Run("session", func(t *testing.T) { + sessDir := filepath.Join(tempDir, "sessions") + os.MkdirAll(sessDir, 0755) + os.Setenv("FETCH_INTERNAL_SESSIONS_DIR", sessDir) + defer os.Unsetenv("FETCH_INTERNAL_SESSIONS_DIR") + + t.Run("cookies persist across requests", func(t *testing.T) { + server := startServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login" { + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + Value: "abc123", + Path: "/", + }) + io.WriteString(w, "logged in") + return + } + if r.URL.Path == "/dashboard" { + cookie, err := r.Cookie("session_id") + if err != nil || cookie.Value != "abc123" { + w.WriteHeader(401) + io.WriteString(w, "unauthorized") + return + } + io.WriteString(w, "welcome") + return + } + }) + defer server.Close() + + // First request: server sets cookie. + res := runFetch(t, fetchPath, server.URL+"/login", "--session", "integ-test") + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, "logged in") + + // Second request: cookie is sent automatically. + res = runFetch(t, fetchPath, server.URL+"/dashboard", "--session", "integ-test") + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, "welcome") + + // Without session: cookie is NOT sent. + res = runFetch(t, fetchPath, server.URL+"/dashboard") + assertExitCode(t, 4, res) + assertBufEquals(t, res.stdout, "unauthorized") + }) + + t.Run("expired cookies are not sent", func(t *testing.T) { + server := startServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/set" { + http.SetCookie(w, &http.Cookie{ + Name: "expired", + Value: "old", + Path: "/", + Expires: time.Now().Add(-time.Hour), + }) + http.SetCookie(w, &http.Cookie{ + Name: "valid", + Value: "yes", + Path: "/", + Expires: time.Now().Add(time.Hour), + }) + return + } + if r.URL.Path == "/check" { + _, err := r.Cookie("expired") + if err == nil { + w.WriteHeader(400) + io.WriteString(w, "expired cookie was sent") + return + } + cookie, err := r.Cookie("valid") + if err != nil || cookie.Value != "yes" { + w.WriteHeader(400) + io.WriteString(w, "valid cookie missing") + return + } + io.WriteString(w, "ok") + return + } + }) + defer server.Close() + + res := runFetch(t, fetchPath, server.URL+"/set", "--session", "expiry-integ") + assertExitCode(t, 0, res) + + res = runFetch(t, fetchPath, server.URL+"/check", "--session", "expiry-integ") + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, "ok") + }) + + t.Run("different session names are isolated", func(t *testing.T) { + server := startServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/set" { + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: r.URL.Query().Get("v"), + Path: "/", + }) + return + } + if r.URL.Path == "/get" { + cookie, err := r.Cookie("token") + if err != nil { + io.WriteString(w, "none") + return + } + io.WriteString(w, cookie.Value) + return + } + }) + defer server.Close() + + // Set different cookies in different sessions. + res := runFetch(t, fetchPath, server.URL+"/set?v=alpha", "--session", "sess-a") + assertExitCode(t, 0, res) + + res = runFetch(t, fetchPath, server.URL+"/set?v=beta", "--session", "sess-b") + assertExitCode(t, 0, res) + + // Verify sessions are isolated. + res = runFetch(t, fetchPath, server.URL+"/get", "--session", "sess-a") + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, "alpha") + + res = runFetch(t, fetchPath, server.URL+"/get", "--session", "sess-b") + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, "beta") + }) + + t.Run("invalid session name rejected", func(t *testing.T) { + res := runFetch(t, fetchPath, "http://example.com", "--session", "../evil") + assertExitCode(t, 1, res) + assertBufContains(t, res.stderr, "session") + }) + }) } type runResult struct { diff --git a/internal/cli/app.go b/internal/cli/app.go index 3b459e1..bb49eb8 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -847,6 +847,19 @@ func (a *App) CLI() *CLI { return nil }, }, + { + Short: "S", + Long: "session", + Args: "NAME", + Description: "Use a named session for cookies", + Default: "", + IsSet: func() bool { + return a.Cfg.Session != nil + }, + Fn: func(value string) error { + return a.Cfg.ParseSession(value) + }, + }, { Short: "t", Long: "timeout", diff --git a/internal/client/client.go b/internal/client/client.go index c95c7d7..b66cf97 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -306,6 +306,11 @@ func (t *http3TimingTransport) RoundTrip(req *http.Request) (*http.Response, err return resp, err } +// SetJar sets the cookie jar on the HTTP client. +func (c *Client) SetJar(jar http.CookieJar) { + c.c.Jar = jar +} + // RequestConfig represents the configuration for creating an HTTP request. type RequestConfig struct { AWSSigV4 *aws.Config diff --git a/internal/config/config.go b/internal/config/config.go index 8309f09..43e1e65 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ import ( "time" "github.com/ryanfowler/fetch/internal/core" + "github.com/ryanfowler/fetch/internal/session" ) // Config represents the configuration options for fetch. @@ -39,6 +40,7 @@ type Config struct { Proxy *url.URL QueryParams []core.KeyVal[string] Redirects *int + Session *string Silent *bool Timeout *time.Duration TLS *uint16 @@ -100,6 +102,9 @@ func (c *Config) Merge(c2 *Config) { if c.Redirects == nil { c.Redirects = c2.Redirects } + if c.Session == nil { + c.Session = c2.Session + } if c.Silent == nil { c.Silent = c2.Silent } @@ -152,6 +157,8 @@ func (c *Config) Set(key, val string) error { err = c.ParseQuery(val) case "redirects": err = c.ParseRedirects(val) + case "session": + err = c.ParseSession(val) case "silent": err = c.ParseSilent(val) case "timeout": @@ -431,6 +438,15 @@ func (c *Config) ParseRedirects(value string) error { return nil } +func (c *Config) ParseSession(value string) error { + if !session.IsValidName(value) { + const usage = "must contain only alphanumeric characters, hyphens, and underscores" + return core.NewValueError("session", value, usage, c.isFile) + } + c.Session = &value + return nil +} + func (c *Config) ParseSilent(value string) error { v, err := strconv.ParseBool(value) if err != nil { diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 6af4e20..c20e60a 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "errors" + "fmt" "io" "mime" "net/http" @@ -25,6 +26,7 @@ import ( "github.com/ryanfowler/fetch/internal/image" "github.com/ryanfowler/fetch/internal/multipart" "github.com/ryanfowler/fetch/internal/proto" + "github.com/ryanfowler/fetch/internal/session" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -81,6 +83,7 @@ type Request struct { Redirects *int RemoteHeaderName bool RemoteName bool + Session string Timeout time.Duration TLS uint16 UnixSocket string @@ -142,6 +145,24 @@ func fetch(ctx context.Context, r *Request) (int, error) { TLS: r.TLS, UnixSocket: r.UnixSocket, }) + + // Load session and set cookie jar, if configured. + var sess *session.Session + if r.Session != "" { + var loadErr error + sess, loadErr = session.Load(r.Session) + if loadErr != nil { + if sess == nil { + return 0, loadErr + } + // Session file was corrupted; warn and start fresh. + p := r.PrinterHandle.Stderr() + msg := fmt.Sprintf("session '%s' is corrupted, starting fresh: %s", r.Session, loadErr.Error()) + core.WriteWarningMsg(p, msg) + } + c.SetJar(sess.Jar()) + } + req, err := c.NewRequest(ctx, client.RequestConfig{ AWSSigV4: r.AWSSigv4, Basic: r.Basic, @@ -239,7 +260,18 @@ func fetch(ctx context.Context, r *Request) (int, error) { } // 8. Make request. - return makeRequest(ctx, r, c, req) + code, err := makeRequest(ctx, r, c, req) + + // Save session cookies after request completes. + if sess != nil { + if saveErr := sess.Save(); saveErr != nil { + p := r.PrinterHandle.Stderr() + msg := fmt.Sprintf("unable to save session '%s': %s", sess.Name, saveErr.Error()) + core.WriteWarningMsg(p, msg) + } + } + + return code, err } func makeRequest(ctx context.Context, r *Request, c *client.Client, req *http.Request) (int, error) { diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..9f3169a --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,236 @@ +package session + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "time" +) + +var validName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// IsValidName returns true if the session name contains only +// alphanumeric characters, hyphens, and underscores. +func IsValidName(name string) bool { + return validName.MatchString(name) +} + +// SessionCookie represents a JSON-serializable cookie. +type SessionCookie struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain"` + Path string `json:"path,omitzero"` + Expires time.Time `json:"expires,omitzero"` + Secure bool `json:"secure,omitzero"` + HttpOnly bool `json:"http_only,omitzero"` + SameSite string `json:"same_site,omitzero"` +} + +// sessionFile is the on-disk JSON format. +type sessionFile struct { + Cookies []SessionCookie `json:"cookies"` +} + +// Session represents a named cookie session. +type Session struct { + Name string + Cookies []SessionCookie + path string +} + +// Load loads a session from disk or creates a new empty session. +// Expired cookies are filtered out on load. +func Load(name string) (*Session, error) { + dir, err := getSessionsDir() + if err != nil { + return nil, err + } + + path := filepath.Join(dir, name+".json") + s := &Session{ + Name: name, + path: path, + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, err + } + + var f sessionFile + if err := json.Unmarshal(data, &f); err != nil { + return s, err + } + + // Filter expired cookies. + now := time.Now() + cookies := make([]SessionCookie, 0, len(f.Cookies)) + for _, c := range f.Cookies { + if !c.Expires.IsZero() && c.Expires.Before(now) { + continue + } + cookies = append(cookies, c) + } + s.Cookies = cookies + + return s, nil +} + +// Save atomically writes the session to disk. +func (s *Session) Save() error { + f := sessionFile{Cookies: s.Cookies} + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + // Atomic write: write to temp file, then rename. + dir := filepath.Dir(s.path) + tmp, err := os.CreateTemp(dir, ".session-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + + return os.Rename(tmpPath, s.path) +} + +// Jar returns an http.CookieJar that persists cookies to this session. +func (s *Session) Jar() http.CookieJar { + jar, _ := cookiejar.New(nil) + + // Pre-populate the jar with saved cookies, grouped by URL. + byURL := make(map[string][]*http.Cookie) + for _, c := range s.Cookies { + scheme := "http" + if c.Secure { + scheme = "https" + } + key := fmt.Sprintf("%s://%s%s", scheme, c.Domain, c.Path) + hc := &http.Cookie{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + Expires: c.Expires, + Secure: c.Secure, + HttpOnly: c.HttpOnly, + } + switch c.SameSite { + case "lax": + hc.SameSite = http.SameSiteLaxMode + case "strict": + hc.SameSite = http.SameSiteStrictMode + case "none": + hc.SameSite = http.SameSiteNoneMode + } + byURL[key] = append(byURL[key], hc) + } + for rawURL, cookies := range byURL { + u, err := url.Parse(rawURL) + if err != nil { + continue + } + jar.SetCookies(u, cookies) + } + + return &sessionJar{jar: jar, session: s} +} + +// sessionJar wraps a cookiejar.Jar and records cookies for persistence. +type sessionJar struct { + jar *cookiejar.Jar + session *Session +} + +func (j *sessionJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.jar.SetCookies(u, cookies) + + // Record cookies into the session. + for _, c := range cookies { + sc := SessionCookie{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + Expires: c.Expires, + Secure: c.Secure, + HttpOnly: c.HttpOnly, + } + if sc.Domain == "" { + sc.Domain = u.Hostname() + } + if sc.Path == "" { + sc.Path = "/" + } + switch c.SameSite { + case http.SameSiteLaxMode: + sc.SameSite = "lax" + case http.SameSiteStrictMode: + sc.SameSite = "strict" + case http.SameSiteNoneMode: + sc.SameSite = "none" + } + + // 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 { + j.session.Cookies[i] = sc + found = true + break + } + } + if !found { + j.session.Cookies = append(j.session.Cookies, sc) + } + } +} + +func (j *sessionJar) Cookies(u *url.URL) []*http.Cookie { + return j.jar.Cookies(u) +} + +func getSessionsDir() (string, error) { + // Allow override for testing. + if dir := os.Getenv("FETCH_INTERNAL_SESSIONS_DIR"); dir != "" { + err := os.MkdirAll(dir, 0755) + if err != nil { + return "", err + } + return dir, nil + } + + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + + path := filepath.Join(dir, "fetch", "sessions") + err = os.MkdirAll(path, 0755) + if err != nil { + return "", err + } + + return path, nil +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..46a0235 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,252 @@ +package session + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + "time" +) + +func TestIsValidName(t *testing.T) { + valid := []string{ + "default", + "api-prod", + "my_session", + "Session1", + "a", + "a-b_c-123", + } + for _, name := range valid { + if !IsValidName(name) { + t.Errorf("expected %q to be valid", name) + } + } + + invalid := []string{ + "", + "../etc/passwd", + "session name", + "session/name", + "session.name", + "session\x00name", + ".hidden", + } + for _, name := range invalid { + if IsValidName(name) { + t.Errorf("expected %q to be invalid", name) + } + } +} + +func TestLoadSaveRoundTrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("FETCH_INTERNAL_SESSIONS_DIR", dir) + + // Load a non-existent session: should return empty. + sess, err := Load("test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sess.Name != "test" { + t.Fatalf("unexpected name: %s", sess.Name) + } + if len(sess.Cookies) != 0 { + t.Fatalf("expected no cookies, got %d", len(sess.Cookies)) + } + + // Add cookies and save. + sess.Cookies = []SessionCookie{ + { + Name: "session_id", + Value: "abc123", + Domain: "example.com", + Path: "/", + Expires: time.Now().Add(time.Hour).Truncate(time.Second), + Secure: true, + HttpOnly: true, + }, + { + Name: "theme", + Value: "dark", + Domain: "example.com", + Path: "/", + }, + } + if err := sess.Save(); err != nil { + t.Fatalf("unexpected save error: %v", err) + } + + // Verify file exists. + path := filepath.Join(dir, "test.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("session file not found: %v", err) + } + + // Load again and verify. + sess2, err := Load("test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sess2.Cookies) != 2 { + t.Fatalf("expected 2 cookies, got %d", len(sess2.Cookies)) + } + if sess2.Cookies[0].Name != "session_id" || sess2.Cookies[0].Value != "abc123" { + t.Fatalf("unexpected cookie: %+v", sess2.Cookies[0]) + } + if sess2.Cookies[0].Secure != true || sess2.Cookies[0].HttpOnly != true { + t.Fatalf("unexpected cookie flags: %+v", sess2.Cookies[0]) + } + if sess2.Cookies[1].Name != "theme" || sess2.Cookies[1].Value != "dark" { + t.Fatalf("unexpected cookie: %+v", sess2.Cookies[1]) + } +} + +func TestExpiredCookiesFiltered(t *testing.T) { + dir := t.TempDir() + t.Setenv("FETCH_INTERNAL_SESSIONS_DIR", dir) + + sess, err := Load("expiry-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + sess.Cookies = []SessionCookie{ + { + Name: "valid", + Value: "yes", + Domain: "example.com", + Path: "/", + Expires: time.Now().Add(time.Hour), + }, + { + Name: "expired", + Value: "no", + Domain: "example.com", + Path: "/", + Expires: time.Now().Add(-time.Hour), + }, + { + Name: "no-expiry", + Value: "session", + Domain: "example.com", + Path: "/", + }, + } + if err := sess.Save(); err != nil { + t.Fatalf("unexpected save error: %v", err) + } + + // Reload: expired cookie should be gone. + sess2, err := Load("expiry-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sess2.Cookies) != 2 { + t.Fatalf("expected 2 cookies, got %d", len(sess2.Cookies)) + } + for _, c := range sess2.Cookies { + if c.Name == "expired" { + t.Fatal("expired cookie should have been filtered") + } + } +} + +func TestSessionJar(t *testing.T) { + dir := t.TempDir() + t.Setenv("FETCH_INTERNAL_SESSIONS_DIR", dir) + + sess, err := Load("jar-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + jar := sess.Jar() + u, _ := url.Parse("http://example.com/path") + + // Set cookies via the jar. + jar.SetCookies(u, []*http.Cookie{ + {Name: "a", Value: "1"}, + {Name: "b", Value: "2"}, + }) + + // Cookies should be retrievable from the jar. + cookies := jar.Cookies(u) + if len(cookies) != 2 { + t.Fatalf("expected 2 cookies from jar, got %d", len(cookies)) + } + + // Cookies should be recorded in the session. + if len(sess.Cookies) != 2 { + t.Fatalf("expected 2 session cookies, got %d", len(sess.Cookies)) + } + + // Save and reload. + if err := sess.Save(); err != nil { + t.Fatalf("unexpected save error: %v", err) + } + + sess2, err := Load("jar-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sess2.Cookies) != 2 { + t.Fatalf("expected 2 cookies after reload, got %d", len(sess2.Cookies)) + } +} + +func TestSessionJarUpdatesExisting(t *testing.T) { + dir := t.TempDir() + t.Setenv("FETCH_INTERNAL_SESSIONS_DIR", dir) + + sess, err := Load("update-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + jar := sess.Jar() + u, _ := url.Parse("http://example.com/") + + // Set initial cookie. + jar.SetCookies(u, []*http.Cookie{ + {Name: "token", Value: "old"}, + }) + if len(sess.Cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(sess.Cookies)) + } + + // Update the same cookie. + jar.SetCookies(u, []*http.Cookie{ + {Name: "token", Value: "new"}, + }) + if len(sess.Cookies) != 1 { + t.Fatalf("expected 1 cookie after update, got %d", len(sess.Cookies)) + } + if sess.Cookies[0].Value != "new" { + t.Fatalf("expected updated value, got %s", sess.Cookies[0].Value) + } +} + +func TestCorruptedSessionFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("FETCH_INTERNAL_SESSIONS_DIR", dir) + + // Write a corrupted file. + path := filepath.Join(dir, "corrupt.json") + if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Load should return the session and a parse error. + sess, err := Load("corrupt") + if err == nil { + t.Fatal("expected error for corrupted session") + } + if sess == nil { + t.Fatal("expected non-nil session even when corrupted") + } + if len(sess.Cookies) != 0 { + t.Fatalf("expected no cookies, got %d", len(sess.Cookies)) + } +} diff --git a/main.go b/main.go index cb70644..0782fad 100644 --- a/main.go +++ b/main.go @@ -166,6 +166,7 @@ func main() { Redirects: app.Cfg.Redirects, RemoteHeaderName: app.RemoteHeaderName, RemoteName: app.RemoteName, + Session: getValue(app.Cfg.Session), Timeout: getValue(app.Cfg.Timeout), TLS: getValue(app.Cfg.TLS), UnixSocket: app.UnixSocket,