Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions docs/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<NAME>.json`
- **macOS**: `~/Library/Caches/fetch/sessions/<NAME>.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
Expand Down
23 changes: 23 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<NAME>.json`
- **macOS**: `~/Library/Caches/fetch/sessions/<NAME>.json`

Can also be configured per-host in the [configuration file](configuration.md).

## Network Options

### `-t, --timeout SECONDS`
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
136 changes: 136 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 {
Expand Down
Loading