net/http: add ParseCookie, ParseSetCookie

Fixes #66008

Change-Id: I64acb7da47a03bdef955f394682004906245a18b
Reviewed-on: https://go-review.googlesource.com/c/go/+/578275
Reviewed-by: Damien Neil <dneil@google.com>
Auto-Submit: Cherry Mui <cherryyz@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Cherry Mui <cherryyz@google.com>
diff --git a/api/next/66008.txt b/api/next/66008.txt
new file mode 100644
index 0000000..ea72f64
--- /dev/null
+++ b/api/next/66008.txt
@@ -0,0 +1,2 @@
+pkg net/http, func ParseCookie(string) ([]*Cookie, error) #66008
+pkg net/http, func ParseSetCookie(string) (*Cookie, error) #66008
diff --git a/doc/next/6-stdlib/99-minor/net/http/66008.md b/doc/next/6-stdlib/99-minor/net/http/66008.md
new file mode 100644
index 0000000..e860370
--- /dev/null
+++ b/doc/next/6-stdlib/99-minor/net/http/66008.md
@@ -0,0 +1,7 @@
+The new [ParseCookie] function parses a Cookie header value and
+returns all the cookies which were set in it. Since the same cookie
+name can appear multiple times the returned Values can contain
+more than one value for a given key.
+
+The new [ParseSetCookie] function parses a Set-Cookie header value and
+returns a cookie. It returns an error on syntax error.
diff --git a/src/net/http/cookie.go b/src/net/http/cookie.go
index c22897f..ab84625 100644
--- a/src/net/http/cookie.go
+++ b/src/net/http/cookie.go
@@ -55,6 +55,140 @@
 	SameSiteNoneMode
 )
 
+var (
+	errBlankCookie           = errors.New("http: blank cookie")
+	errEqualNotFoundInCookie = errors.New("http: '=' not found in cookie")
+	errInvalidCookieName     = errors.New("http: invalid cookie name")
+	errInvalidCookieValue    = errors.New("http: invalid cookie value")
+)
+
+// ParseCookie parses a Cookie header value and returns all the cookies
+// which were set in it. Since the same cookie name can appear multiple times
+// the returned Values can contain more than one value for a given key.
+func ParseCookie(line string) ([]*Cookie, error) {
+	parts := strings.Split(textproto.TrimString(line), ";")
+	if len(parts) == 1 && parts[0] == "" {
+		return nil, errBlankCookie
+	}
+	cookies := make([]*Cookie, 0, len(parts))
+	for _, s := range parts {
+		s = textproto.TrimString(s)
+		name, value, found := strings.Cut(s, "=")
+		if !found {
+			return nil, errEqualNotFoundInCookie
+		}
+		if !isCookieNameValid(name) {
+			return nil, errInvalidCookieName
+		}
+		value, found = parseCookieValue(value, true)
+		if !found {
+			return nil, errInvalidCookieValue
+		}
+		cookies = append(cookies, &Cookie{Name: name, Value: value})
+	}
+	return cookies, nil
+}
+
+// ParseSetCookie parses a Set-Cookie header value and returns a cookie.
+// It returns an error on syntax error.
+func ParseSetCookie(line string) (*Cookie, error) {
+	parts := strings.Split(textproto.TrimString(line), ";")
+	if len(parts) == 1 && parts[0] == "" {
+		return nil, errBlankCookie
+	}
+	parts[0] = textproto.TrimString(parts[0])
+	name, value, ok := strings.Cut(parts[0], "=")
+	if !ok {
+		return nil, errEqualNotFoundInCookie
+	}
+	name = textproto.TrimString(name)
+	if !isCookieNameValid(name) {
+		return nil, errInvalidCookieName
+	}
+	value, ok = parseCookieValue(value, true)
+	if !ok {
+		return nil, errInvalidCookieValue
+	}
+	c := &Cookie{
+		Name:  name,
+		Value: value,
+		Raw:   line,
+	}
+	for i := 1; i < len(parts); i++ {
+		parts[i] = textproto.TrimString(parts[i])
+		if len(parts[i]) == 0 {
+			continue
+		}
+
+		attr, val, _ := strings.Cut(parts[i], "=")
+		lowerAttr, isASCII := ascii.ToLower(attr)
+		if !isASCII {
+			continue
+		}
+		val, ok = parseCookieValue(val, false)
+		if !ok {
+			c.Unparsed = append(c.Unparsed, parts[i])
+			continue
+		}
+
+		switch lowerAttr {
+		case "samesite":
+			lowerVal, ascii := ascii.ToLower(val)
+			if !ascii {
+				c.SameSite = SameSiteDefaultMode
+				continue
+			}
+			switch lowerVal {
+			case "lax":
+				c.SameSite = SameSiteLaxMode
+			case "strict":
+				c.SameSite = SameSiteStrictMode
+			case "none":
+				c.SameSite = SameSiteNoneMode
+			default:
+				c.SameSite = SameSiteDefaultMode
+			}
+			continue
+		case "secure":
+			c.Secure = true
+			continue
+		case "httponly":
+			c.HttpOnly = true
+			continue
+		case "domain":
+			c.Domain = val
+			continue
+		case "max-age":
+			secs, err := strconv.Atoi(val)
+			if err != nil || secs != 0 && val[0] == '0' {
+				break
+			}
+			if secs <= 0 {
+				secs = -1
+			}
+			c.MaxAge = secs
+			continue
+		case "expires":
+			c.RawExpires = val
+			exptime, err := time.Parse(time.RFC1123, val)
+			if err != nil {
+				exptime, err = time.Parse("Mon, 02-Jan-2006 15:04:05 MST", val)
+				if err != nil {
+					c.Expires = time.Time{}
+					break
+				}
+			}
+			c.Expires = exptime.UTC()
+			continue
+		case "path":
+			c.Path = val
+			continue
+		}
+		c.Unparsed = append(c.Unparsed, parts[i])
+	}
+	return c, nil
+}
+
 // readSetCookies parses all "Set-Cookie" values from
 // the header h and returns the successfully parsed Cookies.
 func readSetCookies(h Header) []*Cookie {
@@ -64,101 +198,9 @@
 	}
 	cookies := make([]*Cookie, 0, cookieCount)
 	for _, line := range h["Set-Cookie"] {
-		parts := strings.Split(textproto.TrimString(line), ";")
-		if len(parts) == 1 && parts[0] == "" {
-			continue
+		if cookie, err := ParseSetCookie(line); err == nil {
+			cookies = append(cookies, cookie)
 		}
-		parts[0] = textproto.TrimString(parts[0])
-		name, value, ok := strings.Cut(parts[0], "=")
-		if !ok {
-			continue
-		}
-		name = textproto.TrimString(name)
-		if !isCookieNameValid(name) {
-			continue
-		}
-		value, ok = parseCookieValue(value, true)
-		if !ok {
-			continue
-		}
-		c := &Cookie{
-			Name:  name,
-			Value: value,
-			Raw:   line,
-		}
-		for i := 1; i < len(parts); i++ {
-			parts[i] = textproto.TrimString(parts[i])
-			if len(parts[i]) == 0 {
-				continue
-			}
-
-			attr, val, _ := strings.Cut(parts[i], "=")
-			lowerAttr, isASCII := ascii.ToLower(attr)
-			if !isASCII {
-				continue
-			}
-			val, ok = parseCookieValue(val, false)
-			if !ok {
-				c.Unparsed = append(c.Unparsed, parts[i])
-				continue
-			}
-
-			switch lowerAttr {
-			case "samesite":
-				lowerVal, ascii := ascii.ToLower(val)
-				if !ascii {
-					c.SameSite = SameSiteDefaultMode
-					continue
-				}
-				switch lowerVal {
-				case "lax":
-					c.SameSite = SameSiteLaxMode
-				case "strict":
-					c.SameSite = SameSiteStrictMode
-				case "none":
-					c.SameSite = SameSiteNoneMode
-				default:
-					c.SameSite = SameSiteDefaultMode
-				}
-				continue
-			case "secure":
-				c.Secure = true
-				continue
-			case "httponly":
-				c.HttpOnly = true
-				continue
-			case "domain":
-				c.Domain = val
-				continue
-			case "max-age":
-				secs, err := strconv.Atoi(val)
-				if err != nil || secs != 0 && val[0] == '0' {
-					break
-				}
-				if secs <= 0 {
-					secs = -1
-				}
-				c.MaxAge = secs
-				continue
-			case "expires":
-				c.RawExpires = val
-				exptime, err := time.Parse(time.RFC1123, val)
-				if err != nil {
-					exptime, err = time.Parse("Mon, 02-Jan-2006 15:04:05 MST", val)
-					if err != nil {
-						c.Expires = time.Time{}
-						break
-					}
-				}
-				c.Expires = exptime.UTC()
-				continue
-			case "path":
-				c.Path = val
-				continue
-			}
-			c.Unparsed = append(c.Unparsed, parts[i])
-		}
-		cookies = append(cookies, c)
 	}
 	return cookies
 }
diff --git a/src/net/http/cookie_test.go b/src/net/http/cookie_test.go
index e5bd46a..5337c33 100644
--- a/src/net/http/cookie_test.go
+++ b/src/net/http/cookie_test.go
@@ -6,6 +6,7 @@
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"log"
 	"os"
@@ -650,3 +651,205 @@
 		b.Fatalf("readCookies:\nhave: %s\nwant: %s\n", toJSON(c), toJSON(wantCookies))
 	}
 }
+
+func TestParseCookie(t *testing.T) {
+	tests := []struct {
+		line    string
+		cookies []*Cookie
+		err     error
+	}{
+		{
+			line:    "Cookie-1=v$1",
+			cookies: []*Cookie{{Name: "Cookie-1", Value: "v$1"}},
+		},
+		{
+			line:    "Cookie-1=v$1;c2=v2",
+			cookies: []*Cookie{{Name: "Cookie-1", Value: "v$1"}, {Name: "c2", Value: "v2"}},
+		},
+		{
+			line:    `Cookie-1="v$1";c2="v2"`,
+			cookies: []*Cookie{{Name: "Cookie-1", Value: "v$1"}, {Name: "c2", Value: "v2"}},
+		},
+		{
+			line:    "k1=",
+			cookies: []*Cookie{{Name: "k1", Value: ""}},
+		},
+		{
+			line: "",
+			err:  errBlankCookie,
+		},
+		{
+			line: "whatever",
+			err:  errEqualNotFoundInCookie,
+		},
+		{
+			line: "=v1",
+			err:  errInvalidCookieName,
+		},
+		{
+			line: "k1=\\",
+			err:  errInvalidCookieValue,
+		},
+	}
+	for i, tt := range tests {
+		gotCookies, gotErr := ParseCookie(tt.line)
+		if !errors.Is(gotErr, tt.err) {
+			t.Errorf("#%d ParseCookie got error %v, want error %v", i, gotErr, tt.err)
+		}
+		if !reflect.DeepEqual(gotCookies, tt.cookies) {
+			t.Errorf("#%d ParseCookie:\ngot cookies: %s\nwant cookies: %s\n", i, toJSON(gotCookies), toJSON(tt.cookies))
+		}
+	}
+}
+
+func TestParseSetCookie(t *testing.T) {
+	tests := []struct {
+		line   string
+		cookie *Cookie
+		err    error
+	}{
+		{
+			line:   "Cookie-1=v$1",
+			cookie: &Cookie{Name: "Cookie-1", Value: "v$1", Raw: "Cookie-1=v$1"},
+		},
+		{
+			line: "NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly",
+			cookie: &Cookie{
+				Name:       "NID",
+				Value:      "99=YsDT5i3E-CXax-",
+				Path:       "/",
+				Domain:     ".google.ch",
+				HttpOnly:   true,
+				Expires:    time.Date(2011, 11, 23, 1, 5, 3, 0, time.UTC),
+				RawExpires: "Wed, 23-Nov-2011 01:05:03 GMT",
+				Raw:        "NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly",
+			},
+		},
+		{
+			line: ".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly",
+			cookie: &Cookie{
+				Name:       ".ASPXAUTH",
+				Value:      "7E3AA",
+				Path:       "/",
+				Expires:    time.Date(2012, 3, 7, 14, 25, 6, 0, time.UTC),
+				RawExpires: "Wed, 07-Mar-2012 14:25:06 GMT",
+				HttpOnly:   true,
+				Raw:        ".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly",
+			},
+		},
+		{
+			line: "ASP.NET_SessionId=foo; path=/; HttpOnly",
+			cookie: &Cookie{
+				Name:     "ASP.NET_SessionId",
+				Value:    "foo",
+				Path:     "/",
+				HttpOnly: true,
+				Raw:      "ASP.NET_SessionId=foo; path=/; HttpOnly",
+			},
+		},
+		{
+			line: "samesitedefault=foo; SameSite",
+			cookie: &Cookie{
+				Name:     "samesitedefault",
+				Value:    "foo",
+				SameSite: SameSiteDefaultMode,
+				Raw:      "samesitedefault=foo; SameSite",
+			},
+		},
+		{
+			line: "samesiteinvalidisdefault=foo; SameSite=invalid",
+			cookie: &Cookie{
+				Name:     "samesiteinvalidisdefault",
+				Value:    "foo",
+				SameSite: SameSiteDefaultMode,
+				Raw:      "samesiteinvalidisdefault=foo; SameSite=invalid",
+			},
+		},
+		{
+			line: "samesitelax=foo; SameSite=Lax",
+			cookie: &Cookie{
+				Name:     "samesitelax",
+				Value:    "foo",
+				SameSite: SameSiteLaxMode,
+				Raw:      "samesitelax=foo; SameSite=Lax",
+			},
+		},
+		{
+			line: "samesitestrict=foo; SameSite=Strict",
+			cookie: &Cookie{
+				Name:     "samesitestrict",
+				Value:    "foo",
+				SameSite: SameSiteStrictMode,
+				Raw:      "samesitestrict=foo; SameSite=Strict",
+			},
+		},
+		{
+			line: "samesitenone=foo; SameSite=None",
+			cookie: &Cookie{
+				Name:     "samesitenone",
+				Value:    "foo",
+				SameSite: SameSiteNoneMode,
+				Raw:      "samesitenone=foo; SameSite=None",
+			},
+		},
+		// Make sure we can properly read back the Set-Cookie headers we create
+		// for values containing spaces or commas:
+		{
+			line:   `special-1=a z`,
+			cookie: &Cookie{Name: "special-1", Value: "a z", Raw: `special-1=a z`},
+		},
+		{
+			line:   `special-2=" z"`,
+			cookie: &Cookie{Name: "special-2", Value: " z", Raw: `special-2=" z"`},
+		},
+		{
+			line:   `special-3="a "`,
+			cookie: &Cookie{Name: "special-3", Value: "a ", Raw: `special-3="a "`},
+		},
+		{
+			line:   `special-4=" "`,
+			cookie: &Cookie{Name: "special-4", Value: " ", Raw: `special-4=" "`},
+		},
+		{
+			line:   `special-5=a,z`,
+			cookie: &Cookie{Name: "special-5", Value: "a,z", Raw: `special-5=a,z`},
+		},
+		{
+			line:   `special-6=",z"`,
+			cookie: &Cookie{Name: "special-6", Value: ",z", Raw: `special-6=",z"`},
+		},
+		{
+			line:   `special-7=a,`,
+			cookie: &Cookie{Name: "special-7", Value: "a,", Raw: `special-7=a,`},
+		},
+		{
+			line:   `special-8=","`,
+			cookie: &Cookie{Name: "special-8", Value: ",", Raw: `special-8=","`},
+		},
+		{
+			line: "",
+			err:  errBlankCookie,
+		},
+		{
+			line: "whatever",
+			err:  errEqualNotFoundInCookie,
+		},
+		{
+			line: "=v1",
+			err:  errInvalidCookieName,
+		},
+		{
+			line: "k1=\\",
+			err:  errInvalidCookieValue,
+		},
+	}
+	for i, tt := range tests {
+		gotCookie, gotErr := ParseSetCookie(tt.line)
+		if !errors.Is(gotErr, tt.err) {
+			t.Errorf("#%d ParseCookie got error %v, want error %v", i, gotErr, tt.err)
+		}
+		if !reflect.DeepEqual(gotCookie, tt.cookie) {
+			t.Errorf("#%d ParseCookie:\ngot cookie: %s\nwant cookie: %s\n", i, toJSON(gotCookie), toJSON(tt.cookie))
+		}
+	}
+}