feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// Jar wraps http.CookieJar with Netscape format import/export.
|
||||
type Jar struct {
|
||||
*cookiejar.Jar
|
||||
mu sync.Mutex
|
||||
cookies map[string][]*http.Cookie // domain -> cookies (for export)
|
||||
}
|
||||
|
||||
// NewJar creates a new cookie jar with public suffix support.
|
||||
func NewJar() (*Jar, error) {
|
||||
jar, err := cookiejar.New(&cookiejar.Options{
|
||||
PublicSuffixList: publicsuffix.List,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Jar{
|
||||
Jar: jar,
|
||||
cookies: make(map[string][]*http.Cookie),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetCookies stores cookies in the jar and tracks them for export.
|
||||
func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.Jar.SetCookies(u, cookies)
|
||||
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
for _, c := range cookies {
|
||||
// Key the export map by the cookie's effective domain, not the
|
||||
// URL hostname. A cookie set for "example.com" via a request to
|
||||
// "www.example.com" must round-trip through SaveToFile/LoadFromFile
|
||||
// keyed by "example.com", otherwise the second SetCookies call
|
||||
// (from LoadFromFile) lands under a different map key and the
|
||||
// cookies appear to be silently lost between sessions.
|
||||
key := cookieStorageKey(c, u)
|
||||
j.cookies[key] = append(j.cookies[key], c)
|
||||
}
|
||||
}
|
||||
|
||||
// cookieStorageKey returns the canonical (no leading dot) domain used as
|
||||
// the storage key for the export map. It prefers the cookie's own Domain
|
||||
// attribute (which reflects the cookie's actual scope) and falls back to
|
||||
// the URL hostname for host-only cookies (those without a Domain attr).
|
||||
func cookieStorageKey(c *http.Cookie, u *url.URL) string {
|
||||
if c.Domain != "" {
|
||||
return strings.TrimPrefix(c.Domain, ".")
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// LoadFromFile loads cookies from a file in Netscape format.
|
||||
func (j *Jar) LoadFromFile(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to open cookie jar: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 7 {
|
||||
continue
|
||||
}
|
||||
|
||||
domain := fields[0]
|
||||
path := fields[2]
|
||||
expiresStr := fields[4]
|
||||
name := fields[5]
|
||||
value := fields[6]
|
||||
|
||||
expires, err := strconv.ParseInt(expiresStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if expires > 0 && time.Now().Unix() > expires {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip the leading dot from the Netscape-format domain so the
|
||||
// round-trip back through SetCookies uses a stable key. Go's
|
||||
// net/url strips the leading dot from Hostname() since Go 1.20,
|
||||
// but matching that behaviour here makes the export map key
|
||||
// independent of Go stdlib version.
|
||||
domainBare := strings.TrimPrefix(domain, ".")
|
||||
|
||||
scheme := "https"
|
||||
if fields[3] != "TRUE" {
|
||||
scheme = "http"
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: domainBare,
|
||||
Path: path,
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: path,
|
||||
Domain: domainBare,
|
||||
Expires: time.Unix(expires, 0),
|
||||
Secure: fields[3] == "TRUE",
|
||||
}
|
||||
|
||||
j.SetCookies(u, []*http.Cookie{cookie})
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// SaveToFile saves cookies to a file in Netscape format.
|
||||
func (j *Jar) SaveToFile(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cookie jar: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fmt.Fprintln(file, "# Netscape HTTP Cookie File")
|
||||
fmt.Fprintf(file, "# Generated by goget %s at %s\n", core.Version, time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintln(file, "# Format: domain flag path secure expiration name value")
|
||||
fmt.Fprintln(file)
|
||||
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
|
||||
for domain, cookies := range j.cookies {
|
||||
for _, c := range cookies {
|
||||
secure := "FALSE"
|
||||
if c.Secure {
|
||||
secure = "TRUE"
|
||||
}
|
||||
// The map key is always stored without a leading dot, but
|
||||
// the Netscape format expects one — always emit it.
|
||||
expires := c.Expires.Unix()
|
||||
if expires <= 0 {
|
||||
expires = time.Now().Add(365 * 24 * time.Hour).Unix()
|
||||
}
|
||||
fmt.Fprintf(file, ".%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
|
||||
domain,
|
||||
"TRUE",
|
||||
c.Path,
|
||||
secure,
|
||||
expires,
|
||||
c.Name,
|
||||
c.Value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCookieCount returns the number of cookies for a given URL.
|
||||
func (j *Jar) GetCookieCount(u *url.URL) int {
|
||||
return len(j.Cookies(u))
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// futureTimestamp returns a unix timestamp safely in the future.
|
||||
func futureTimestamp() int64 {
|
||||
return time.Now().Unix() + 86400*365 // ~1 year from now
|
||||
}
|
||||
|
||||
// pastTimestamp returns a unix timestamp safely in the past.
|
||||
func pastTimestamp() int64 {
|
||||
return time.Now().Unix() - 86400 // 1 day ago
|
||||
}
|
||||
|
||||
// fmtInt64 is a helper to avoid importing strconv in every test.
|
||||
func fmtInt64(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
// writeTempFile writes content to a temp file inside t.TempDir() and returns its path.
|
||||
func writeTempFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// netscapeLine builds a single Netscape-format cookie line.
|
||||
func netscapeLine(domain, domainFlag, path, secure, expires, name, value string) string {
|
||||
return domain + "\t" + domainFlag + "\t" + path + "\t" + secure + "\t" + expires + "\t" + name + "\t" + value
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. TestNewJar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewJar(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJar() returned unexpected error: %v", err)
|
||||
}
|
||||
if j == nil {
|
||||
t.Fatal("NewJar() returned nil")
|
||||
}
|
||||
if j.Jar == nil {
|
||||
t.Fatal("NewJar().Jar is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. TestLoadFromFileEmptyPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLoadFromFileEmptyPath(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(""); err != nil {
|
||||
t.Fatalf("LoadFromFile(\"\") should return nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. TestLoadFromFileNotExist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLoadFromFileNotExist(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile("/tmp/this_file_does_not_exist_xxx.cookie"); err != nil {
|
||||
t.Fatalf("LoadFromFile(non-existent) should return nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. TestLoadFromFile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLoadFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ft := futureTimestamp()
|
||||
content := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".example.com", "TRUE", "/", "FALSE", fmtInt64(ft), "test", "cookie_value") + "\n"
|
||||
|
||||
path := writeTempFile(t, dir, "cookies.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Cookie set with scheme=http (because secure=FALSE), Domain=.example.com, Path=/
|
||||
// Retrieve over http.
|
||||
u, _ := url.Parse("http://sub.example.com/path")
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != "test" {
|
||||
t.Errorf("expected name 'test', got %q", cookies[0].Name)
|
||||
}
|
||||
if cookies[0].Value != "cookie_value" {
|
||||
t.Errorf("expected value 'cookie_value', got %q", cookies[0].Value)
|
||||
}
|
||||
_ = cookies[0].Path // cookiejar does not preserve Path on retrieval
|
||||
}
|
||||
|
||||
// TestLoadFromFileHttps verifies that secure=TRUE results in an https cookie.
|
||||
func TestLoadFromFileHttps(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ft := futureTimestamp()
|
||||
content := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".secure.example.com", "TRUE", "/", "TRUE", fmtInt64(ft), "sess", "abc") + "\n"
|
||||
path := writeTempFile(t, dir, "cookies_secure.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Secure cookie — must be retrieved over HTTPS.
|
||||
u, _ := url.Parse("https://secure.example.com/path")
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 secure cookie over HTTPS, got %d", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != "sess" {
|
||||
t.Errorf("expected name 'sess', got %q", cookies[0].Name)
|
||||
}
|
||||
if cookies[0].Value != "abc" {
|
||||
t.Errorf("expected value 'abc', got %q", cookies[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. TestLoadFromFileCommentsAndEmptyLines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLoadFromFileCommentsAndEmptyLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ft := futureTimestamp()
|
||||
|
||||
content :=
|
||||
"# Netscape HTTP Cookie File\n" +
|
||||
"# This is a comment that should be skipped\n" +
|
||||
"\n" +
|
||||
" \t \n" +
|
||||
netscapeLine(".alpha.com", "TRUE", "/", "FALSE", fmtInt64(ft), "a", "1") + "\n" +
|
||||
"# Another comment\n" +
|
||||
netscapeLine(".beta.com", "TRUE", "/", "FALSE", fmtInt64(ft), "b", "2") + "\n"
|
||||
|
||||
path := writeTempFile(t, dir, "cookies.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Check alpha
|
||||
u, _ := url.Parse("http://alpha.com/path")
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie for alpha.com, got %d", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != "a" {
|
||||
t.Errorf("expected name 'a', got %q", cookies[0].Name)
|
||||
}
|
||||
|
||||
// Check beta
|
||||
u2, _ := url.Parse("http://beta.com/path")
|
||||
cookies2 := j.Cookies(u2)
|
||||
if len(cookies2) != 1 {
|
||||
t.Fatalf("expected 1 cookie for beta.com, got %d", len(cookies2))
|
||||
}
|
||||
if cookies2[0].Name != "b" {
|
||||
t.Errorf("expected name 'b', got %q", cookies2[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. TestLoadFromFileExpiredCookie
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLoadFromFileExpiredCookie(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pt := pastTimestamp()
|
||||
ft := futureTimestamp()
|
||||
|
||||
content := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".expired.com", "TRUE", "/", "FALSE", fmtInt64(pt), "gone", "x") + "\n" +
|
||||
netscapeLine(".valid.com", "TRUE", "/", "FALSE", fmtInt64(ft), "alive", "y") + "\n"
|
||||
|
||||
path := writeTempFile(t, dir, "cookies.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Expired cookie should not be stored.
|
||||
u, _ := url.Parse("http://expired.com/path")
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 0 {
|
||||
t.Errorf("expected 0 cookies for expired domain, got %d", len(cookies))
|
||||
}
|
||||
|
||||
// Valid cookie should be stored.
|
||||
u2, _ := url.Parse("http://valid.com/path")
|
||||
cookies2 := j.Cookies(u2)
|
||||
if len(cookies2) != 1 {
|
||||
t.Fatalf("expected 1 cookie for valid domain, got %d", len(cookies2))
|
||||
}
|
||||
if cookies2[0].Name != "alive" {
|
||||
t.Errorf("expected name 'alive', got %q", cookies2[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromFileSessionCookie documents the current behaviour: when expires=0,
|
||||
// LoadFromFile does NOT skip it (because 0 > 0 is false), but time.Unix(0,0)
|
||||
// makes the cookiejar itself treat it as expired. Therefore the cookie is not
|
||||
// retrievable. This is a known limitation of the current implementation.
|
||||
func TestLoadFromFileSessionCookie(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".session.com", "TRUE", "/", "FALSE", "0", "sess", "active") + "\n"
|
||||
path := writeTempFile(t, dir, "session.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// The cookie is loaded (no error) but can not be retrieved because
|
||||
// time.Unix(0,0) is the Unix epoch (1970) which the cookiejar sees as expired.
|
||||
u, _ := url.Parse("http://session.com/path")
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 0 {
|
||||
t.Logf("Note: session cookie (expires=0) currently not retrievable, got %d cookies", len(cookies))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. TestSaveToFileEmptyPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSaveToFileEmptyPath(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.SaveToFile(""); err != nil {
|
||||
t.Fatalf("SaveToFile(\"\") should return nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. TestSaveToFile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSaveToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
savePath := filepath.Join(dir, "subdir", "cookies.txt")
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.SaveToFile(savePath); err != nil {
|
||||
t.Fatalf("SaveToFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// File must exist.
|
||||
if _, err := os.Stat(savePath); os.IsNotExist(err) {
|
||||
t.Fatalf("SaveToFile() did not create file at %s", savePath)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(savePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
if len(content) == 0 {
|
||||
t.Fatal("saved file is empty")
|
||||
}
|
||||
|
||||
// Must contain Netscape header.
|
||||
if !contains(content, "Netscape HTTP Cookie File") {
|
||||
t.Errorf("saved file should contain 'Netscape HTTP Cookie File', got:\n%s", content)
|
||||
}
|
||||
// Must contain the generated-by line.
|
||||
if !contains(content, "Generated by goget") {
|
||||
t.Errorf("saved file should contain 'Generated by goget', got:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveToFileExistingDirectory verifies SaveToFile works when the
|
||||
// directory already exists.
|
||||
func TestSaveToFileExistingDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "already_exists")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
savePath := filepath.Join(dir, "cookies.txt")
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.SaveToFile(savePath); err != nil {
|
||||
t.Fatalf("SaveToFile() returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(savePath); os.IsNotExist(err) {
|
||||
t.Fatalf("file not created in existing directory at %s", savePath)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. TestCookieRoundTrip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCookieRoundTrip(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://roundtrip.example.com/path")
|
||||
j.SetCookies(u, []*http.Cookie{
|
||||
{Name: "session_id", Value: "abc123", Path: "/"},
|
||||
})
|
||||
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != "session_id" {
|
||||
t.Errorf("expected name 'session_id', got %q", cookies[0].Name)
|
||||
}
|
||||
if cookies[0].Value != "abc123" {
|
||||
t.Errorf("expected value 'abc123', got %q", cookies[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieRoundTripHttpsThenHttp verifies that an insecure cookie set over
|
||||
// HTTPS is sent on HTTP as well.
|
||||
func TestCookieRoundTripHttpsThenHttp(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://mix.example.com/path")
|
||||
j.SetCookies(u, []*http.Cookie{
|
||||
{Name: "lang", Value: "en", Path: "/"},
|
||||
})
|
||||
|
||||
httpU, _ := url.Parse("http://mix.example.com/path")
|
||||
cookies := j.Cookies(httpU)
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie over HTTP after HTTPS set, got %d", len(cookies))
|
||||
}
|
||||
if cookies[0].Value != "en" {
|
||||
t.Errorf("expected value 'en', got %q", cookies[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieRoundTripSecure verifies that a secure cookie set over HTTPS is
|
||||
// NOT sent over plain HTTP.
|
||||
func TestCookieRoundTripSecure(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://secure-only.example.com/path")
|
||||
j.SetCookies(u, []*http.Cookie{
|
||||
{Name: "secret", Value: "s3cr3t", Path: "/", Secure: true},
|
||||
})
|
||||
|
||||
// Over HTTPS – should be present.
|
||||
httpsCookies := j.Cookies(u)
|
||||
hasSecret := false
|
||||
for _, c := range httpsCookies {
|
||||
if c.Name == "secret" {
|
||||
hasSecret = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSecret {
|
||||
t.Error("secure cookie should be sent over HTTPS")
|
||||
}
|
||||
|
||||
// Over HTTP – must NOT be present.
|
||||
httpU, _ := url.Parse("http://secure-only.example.com/path")
|
||||
httpCookies := j.Cookies(httpU)
|
||||
for _, c := range httpCookies {
|
||||
if c.Name == "secret" {
|
||||
t.Error("secure cookie should NOT be sent over HTTP")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. TestGetCookieCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetCookieCount(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://count.example.com/path")
|
||||
|
||||
// Empty jar.
|
||||
if c := j.GetCookieCount(u); c != 0 {
|
||||
t.Errorf("expected 0 cookies for empty jar, got %d", c)
|
||||
}
|
||||
|
||||
// Add two cookies.
|
||||
j.SetCookies(u, []*http.Cookie{
|
||||
{Name: "a", Value: "1", Path: "/"},
|
||||
{Name: "b", Value: "2", Path: "/"},
|
||||
})
|
||||
if c := j.GetCookieCount(u); c != 2 {
|
||||
t.Errorf("expected 2 cookies, got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCookieCountDifferentURLs verifies cookie counts are scoped to the URL.
|
||||
func TestGetCookieCountDifferentURLs(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u1, _ := url.Parse("https://left.example.com/path")
|
||||
u2, _ := url.Parse("https://right.example.com/path")
|
||||
|
||||
j.SetCookies(u1, []*http.Cookie{{Name: "only_left", Value: "x", Path: "/"}})
|
||||
|
||||
if c := j.GetCookieCount(u1); c != 1 {
|
||||
t.Errorf("expected 1 cookie for left, got %d", c)
|
||||
}
|
||||
if c := j.GetCookieCount(u2); c != 0 {
|
||||
t.Errorf("expected 0 cookies for right, got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. TestMultipleCookies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMultipleCookies(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://multi.example.com/path")
|
||||
j.SetCookies(u, []*http.Cookie{
|
||||
{Name: "first", Value: "one", Path: "/"},
|
||||
{Name: "second", Value: "two", Path: "/"},
|
||||
{Name: "third", Value: "three", Path: "/"},
|
||||
})
|
||||
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 3 {
|
||||
t.Fatalf("expected 3 cookies, got %d", len(cookies))
|
||||
}
|
||||
|
||||
vals := make(map[string]string)
|
||||
for _, c := range cookies {
|
||||
vals[c.Name] = c.Value
|
||||
}
|
||||
if vals["first"] != "one" {
|
||||
t.Errorf("expected first=one, got first=%q", vals["first"])
|
||||
}
|
||||
if vals["second"] != "two" {
|
||||
t.Errorf("expected second=two, got second=%q", vals["second"])
|
||||
}
|
||||
if vals["third"] != "three" {
|
||||
t.Errorf("expected third=three, got third=%q", vals["third"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleCookiesSameDomain verifies that setting a cookie with the same
|
||||
// name overwrites the previous value.
|
||||
func TestMultipleCookiesSameDomain(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://overwrite.example.com/path")
|
||||
|
||||
j.SetCookies(u, []*http.Cookie{{Name: "lang", Value: "en", Path: "/"}})
|
||||
j.SetCookies(u, []*http.Cookie{{Name: "theme", Value: "dark", Path: "/"}})
|
||||
j.SetCookies(u, []*http.Cookie{{Name: "lang", Value: "fr", Path: "/"}})
|
||||
|
||||
cookies := j.Cookies(u)
|
||||
vals := make(map[string]string)
|
||||
for _, c := range cookies {
|
||||
vals[c.Name] = c.Value
|
||||
}
|
||||
|
||||
if vals["lang"] != "fr" {
|
||||
t.Errorf("expected lang=fr (overwritten), got lang=%q", vals["lang"])
|
||||
}
|
||||
if vals["theme"] != "dark" {
|
||||
t.Errorf("expected theme=dark, got theme=%q", vals["theme"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleCookiesDifferentPaths validates that cookies with different
|
||||
// paths are scoped correctly.
|
||||
func TestMultipleCookiesDifferentPaths(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rootU, _ := url.Parse("https://paths.example.com/")
|
||||
adminU, _ := url.Parse("https://paths.example.com/admin")
|
||||
apiU, _ := url.Parse("https://paths.example.com/api/v1")
|
||||
|
||||
j.SetCookies(rootU, []*http.Cookie{{Name: "root", Value: "r", Path: "/"}})
|
||||
j.SetCookies(adminU, []*http.Cookie{{Name: "admin", Value: "a", Path: "/admin"}})
|
||||
j.SetCookies(apiU, []*http.Cookie{{Name: "api", Value: "v", Path: "/api"}})
|
||||
|
||||
// Root path should only see the root cookie.
|
||||
rootCookies := j.Cookies(rootU)
|
||||
if len(rootCookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie for root path, got %d", len(rootCookies))
|
||||
}
|
||||
if rootCookies[0].Name != "root" {
|
||||
t.Errorf("expected 'root' cookie at root path, got %q", rootCookies[0].Name)
|
||||
}
|
||||
|
||||
// Admin path should see root (/) + admin (/admin).
|
||||
adminCookies := j.Cookies(adminU)
|
||||
adminNames := make(map[string]bool)
|
||||
for _, c := range adminCookies {
|
||||
adminNames[c.Name] = true
|
||||
}
|
||||
if !adminNames["root"] {
|
||||
t.Error("root cookie should be visible under /admin")
|
||||
}
|
||||
if !adminNames["admin"] {
|
||||
t.Error("admin cookie should be visible under /admin")
|
||||
}
|
||||
|
||||
// API path should see root (/) + api (/api).
|
||||
apiCookies := j.Cookies(apiU)
|
||||
apiNames := make(map[string]bool)
|
||||
for _, c := range apiCookies {
|
||||
apiNames[c.Name] = true
|
||||
}
|
||||
if !apiNames["root"] {
|
||||
t.Error("root cookie should be visible under /api")
|
||||
}
|
||||
if !apiNames["api"] {
|
||||
t.Error("api cookie should be visible under /api")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional edge-case tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestLoadFromFileMalformedLine checks that lines with fewer than 7 fields
|
||||
// are silently skipped.
|
||||
func TestLoadFromFileMalformedLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ft := futureTimestamp()
|
||||
|
||||
content := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".good.com", "TRUE", "/", "FALSE", fmtInt64(ft), "ok", "fine") + "\n" +
|
||||
"short.line\n" +
|
||||
".also-short\tTRUE\t/\n" +
|
||||
netscapeLine(".also-good.com", "TRUE", "/", "FALSE", fmtInt64(ft), "ok2", "fine2") + "\n"
|
||||
|
||||
path := writeTempFile(t, dir, "malformed.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// First valid cookie should be present.
|
||||
u1, _ := url.Parse("http://good.com/path")
|
||||
c1 := j.Cookies(u1)
|
||||
if len(c1) != 1 {
|
||||
t.Errorf("expected 1 cookie for good.com, got %d", len(c1))
|
||||
}
|
||||
|
||||
// Second valid cookie should be present.
|
||||
u2, _ := url.Parse("http://also-good.com/path")
|
||||
c2 := j.Cookies(u2)
|
||||
if len(c2) != 1 {
|
||||
t.Errorf("expected 1 cookie for also-good.com, got %d", len(c2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromFileInvalidExpiry checks that an unparsable expiry is skipped.
|
||||
func TestLoadFromFileInvalidExpiry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ft := futureTimestamp()
|
||||
|
||||
content := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".bad-expiry.com", "TRUE", "/", "FALSE", "not_a_number", "x", "y") + "\n" +
|
||||
netscapeLine(".fresh.com", "TRUE", "/", "FALSE", fmtInt64(ft), "good", "yes") + "\n"
|
||||
|
||||
path := writeTempFile(t, dir, "bad_expiry.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
// The malformed-expiry line should be skipped.
|
||||
u1, _ := url.Parse("http://bad-expiry.com/path")
|
||||
c1 := j.Cookies(u1)
|
||||
if len(c1) != 0 {
|
||||
t.Errorf("expected 0 cookies for bad-expiry.com, got %d", len(c1))
|
||||
}
|
||||
|
||||
// The valid line should still work.
|
||||
u2, _ := url.Parse("http://fresh.com/path")
|
||||
c2 := j.Cookies(u2)
|
||||
if len(c2) != 1 {
|
||||
t.Fatalf("expected 1 cookie for fresh.com, got %d", len(c2))
|
||||
}
|
||||
if c2[0].Name != "good" {
|
||||
t.Errorf("expected name 'good', got %q", c2[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAndSaveRoundTrip loads from a file then saves to another location.
|
||||
func TestLoadAndSaveRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ft := futureTimestamp()
|
||||
|
||||
loadContent := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".rt.com", "TRUE", "/", "FALSE", fmtInt64(ft), "a", "b") + "\n"
|
||||
loadPath := writeTempFile(t, dir, "load.txt", loadContent)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(loadPath); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
savePath := filepath.Join(dir, "nested", "save.txt")
|
||||
if err := j.SaveToFile(savePath); err != nil {
|
||||
t.Fatalf("SaveToFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(savePath); os.IsNotExist(err) {
|
||||
t.Errorf("saved file not found at %s", savePath)
|
||||
}
|
||||
data, err := os.ReadFile(savePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(string(data), "Netscape HTTP Cookie File") {
|
||||
t.Error("saved file must contain Netscape header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCookieCountWithLoad verifies GetCookieCount after loading from file.
|
||||
func TestGetCookieCountWithLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ft := futureTimestamp()
|
||||
|
||||
content := "# Netscape HTTP Cookie File\n" +
|
||||
netscapeLine(".cnt1.com", "TRUE", "/", "FALSE", fmtInt64(ft), "c1", "v1") + "\n" +
|
||||
netscapeLine(".cnt1.com", "TRUE", "/", "FALSE", fmtInt64(ft), "c2", "v2") + "\n"
|
||||
path := writeTempFile(t, dir, "count.txt", content)
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.LoadFromFile(path); err != nil {
|
||||
t.Fatalf("LoadFromFile() returned error: %v", err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("http://cnt1.com/path")
|
||||
if c := j.GetCookieCount(u); c != 2 {
|
||||
t.Errorf("expected 2 cookies after load, got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromFileOpenError verifies that an IO error (e.g. a directory
|
||||
// instead of a file) is propagated as an error (not silently swallowed).
|
||||
func TestLoadFromFileOpenError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Try to open a directory – this should fail with an error (not IsNotExist).
|
||||
err = j.LoadFromFile(dir)
|
||||
if err == nil {
|
||||
t.Log("Note: opening a directory may produce an error or not depending on OS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewJarMultiple verifies that multiple jars can be created independently.
|
||||
func TestNewJarMultiple(t *testing.T) {
|
||||
j1, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j2, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://independent.example.com/path")
|
||||
j1.SetCookies(u, []*http.Cookie{{Name: "only_j1", Value: "yes", Path: "/"}})
|
||||
|
||||
if c := len(j1.Cookies(u)); c != 1 {
|
||||
t.Errorf("expected 1 cookie in j1, got %d", c)
|
||||
}
|
||||
if c := len(j2.Cookies(u)); c != 0 {
|
||||
t.Errorf("expected 0 cookies in j2, got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetCookiesUsesCookieDomainForStorageKey is a regression guard for the
|
||||
// BACKLOG entry "Cookie domain mismatch between storage and export".
|
||||
// The previous implementation keyed the export map by u.Hostname() and
|
||||
// the file output by (c.Domain with a possibly-prepended dot). When
|
||||
// stdlib cookiejar normalised c.Domain with a leading dot, the storage
|
||||
// key and the file's domain field could diverge, and round-tripping
|
||||
// through SaveToFile/LoadFromFile would re-key the cookie under a
|
||||
// different domain — silently losing the cookie on the next session.
|
||||
//
|
||||
// The fix is to key storage by the cookie's effective domain (no
|
||||
// leading dot) and to use that same key when writing the file. This
|
||||
// test sets a cookie scoped to "example.com" via a request to
|
||||
// "www.example.com", saves, loads into a fresh jar, and verifies the
|
||||
// cookie is still retrievable.
|
||||
func TestSetCookiesUsesCookieDomainForStorageKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
savePath := filepath.Join(dir, "roundtrip.txt")
|
||||
|
||||
// Session 1: set a cookie whose Domain is "example.com" even
|
||||
// though the URL host is "www.example.com" (the typical case for
|
||||
// a server issuing a cookie that scopes to the parent domain).
|
||||
j1, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := url.Parse("https://www.example.com/path")
|
||||
j1.SetCookies(u, []*http.Cookie{
|
||||
{Name: "session", Value: "abc", Path: "/", Domain: "example.com"},
|
||||
})
|
||||
|
||||
// SaveToFile must write ".example.com" (Netscape format with
|
||||
// leading dot).
|
||||
if err := j1.SaveToFile(savePath); err != nil {
|
||||
t.Fatalf("SaveToFile: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(savePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(string(data), ".example.com\t") {
|
||||
t.Errorf("saved file must contain .example.com entry, got:\n%s", data)
|
||||
}
|
||||
|
||||
// Session 2: load into a fresh jar, the cookie must come back
|
||||
// and be retrievable for the original URL.
|
||||
j2, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j2.LoadFromFile(savePath); err != nil {
|
||||
t.Fatalf("LoadFromFile: %v", err)
|
||||
}
|
||||
|
||||
cookies := j2.Cookies(u)
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie after round-trip, got %d", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != "session" || cookies[0].Value != "abc" {
|
||||
t.Errorf("round-tripped cookie = %+v, want {Name: session, Value: abc}", cookies[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetCookiesHostOnlyCookie is the host-only counterpart: a cookie
|
||||
// without a Domain attribute must be stored under the URL hostname
|
||||
// (the only scope information available).
|
||||
func TestSetCookiesHostOnlyCookie(t *testing.T) {
|
||||
j, err := NewJar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := url.Parse("https://hostonly.example.com/path")
|
||||
j.SetCookies(u, []*http.Cookie{
|
||||
{Name: "k", Value: "v", Path: "/"},
|
||||
})
|
||||
|
||||
cookies := j.Cookies(u)
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
if cookies[0].Name != "k" {
|
||||
t.Errorf("cookie name = %q, want %q", cookies[0].Name, "k")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: contains reports whether substr is in s.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user