001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.net.HttpCookie;
025import java.time.Duration;
026import java.time.Instant;
027import java.time.ZoneId;
028import java.time.ZoneOffset;
029import java.time.format.DateTimeFormatter;
030import java.time.format.DateTimeFormatterBuilder;
031import java.time.format.SignStyle;
032import java.time.temporal.ChronoField;
033import java.util.ArrayList;
034import java.util.List;
035import java.util.Locale;
036import java.util.Objects;
037import java.util.Optional;
038import java.util.stream.Collectors;
039
040import static com.soklet.Utilities.trimAggressivelyToNull;
041import static java.lang.String.format;
042import static java.util.Objects.requireNonNull;
043
044/**
045 * HTTP "response" Cookie representation which supports {@code Set-Cookie} header encoding.
046 * <p>
047 * Threadsafe instances can be acquired via these builder factory methods:
048 * <ul>
049 *   <li>{@link #with(String, String)} (builder primed with name and value)</li>
050 *   <li>{@link #withName(String)} (builder primed with name only)</li>
051 * </ul>
052 *
053 * @author <a href="https://www.revetkn.com">Mark Allen</a>
054 */
055@ThreadSafe
056public final class ResponseCookie {
057        @NonNull
058        private final String name;
059        @Nullable
060        private final String value;
061        @Nullable
062        private final Duration maxAge;
063        @Nullable
064        private final Instant expires;
065        @Nullable
066        private final String domain;
067        @Nullable
068        private final String path;
069        @NonNull
070        private final Boolean secure;
071        @NonNull
072        private final Boolean httpOnly;
073        @Nullable
074        private final SameSite sameSite;
075        @Nullable
076        private final Priority priority;
077        @NonNull
078        private final Boolean partitioned;
079
080        @NonNull
081        private static final DateTimeFormatter RFC_1123_FORMATTER;
082        @NonNull
083        private static final DateTimeFormatter RFC_1036_PARSER;
084        @NonNull
085        private static final DateTimeFormatter ASCTIME_PARSER;
086
087        static {
088                RFC_1123_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneId.of("GMT"));
089                RFC_1036_PARSER = new DateTimeFormatterBuilder()
090                                .parseCaseInsensitive()
091                                .appendPattern("EEE, dd MMM ")
092                                .appendValueReduced(ChronoField.YEAR, 2, 2, 1900)
093                                .appendPattern(" HH:mm:ss zzz")
094                                .toFormatter(Locale.US)
095                                .withZone(ZoneOffset.UTC);
096                ASCTIME_PARSER = new DateTimeFormatterBuilder()
097                                .parseCaseInsensitive()
098                                .appendPattern("EEE MMM")
099                                .appendLiteral(' ')
100                                .optionalStart().appendLiteral(' ').optionalEnd()
101                                .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE)
102                                .appendPattern(" HH:mm:ss yyyy")
103                                .toFormatter(Locale.US)
104                                .withZone(ZoneOffset.UTC);
105        }
106
107        /**
108         * Acquires a builder for {@link ResponseCookie} instances.
109         *
110         * @param name  the cookie name
111         * @param value the cookie value
112         * @return the builder
113         */
114        @NonNull
115        public static Builder with(@NonNull String name,
116                                                                                                                 @Nullable String value) {
117                requireNonNull(name);
118                return new Builder(name, value);
119        }
120
121        /**
122         * Acquires a builder for {@link ResponseCookie} instances without specifying the cookie's value.
123         *
124         * @param name the cookie name
125         * @return the builder
126         */
127        @NonNull
128        public static Builder withName(@NonNull String name) {
129                requireNonNull(name);
130                return new Builder(name);
131        }
132
133        /**
134         * Vends a mutable copier seeded with this instance's data, suitable for building new instances.
135         *
136         * @return a copier for this instance
137         */
138        @NonNull
139        public Copier copy() {
140                return new Copier(this);
141        }
142
143        /**
144         * Given a {@code Set-Cookie} header representation, provide a {@link ResponseCookie} that matches it.
145         * <p>
146         * An example of a {@code Set-Cookie} header representation is {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly}
147         * <p>
148         * Note: while the spec does not forbid multiple cookie name/value pairs to be specified in the same {@code Set-Cookie} header, this format is unusual - Soklet does not currently support parsing these kinds of cookies.
149         * <p>
150         * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie</a> for details.
151         *
152         * @param setCookieHeaderRepresentation a {@code Set-Cookie} header representation
153         * @return a {@link ResponseCookie} representation of the {@code Set-Cookie} header, or {@link Optional#empty()} if the header is null, empty, or does not include cookie data
154         * @throws IllegalArgumentException if the {@code Set-Cookie} header representation is malformed
155         */
156        @NonNull
157        public static Optional<ResponseCookie> fromSetCookieHeaderRepresentation(@Nullable String setCookieHeaderRepresentation) {
158                setCookieHeaderRepresentation = setCookieHeaderRepresentation == null ? null : setCookieHeaderRepresentation.trim();
159
160                if (setCookieHeaderRepresentation == null || setCookieHeaderRepresentation.length() == 0)
161                        return Optional.empty();
162
163                String normalizedSetCookieHeaderRepresentation = stripSetCookiePrefix(setCookieHeaderRepresentation);
164
165                if (normalizedSetCookieHeaderRepresentation == null || normalizedSetCookieHeaderRepresentation.length() == 0)
166                        return Optional.empty();
167
168                List<HttpCookie> cookies = HttpCookie.parse(normalizedSetCookieHeaderRepresentation);
169
170                if (cookies.size() == 0)
171                        return Optional.empty();
172
173                // Technically OK per the spec to "fold" multiple cookie name/value pairs into the same header but this is
174                // unusual, and we don't support it here.  Pick the first cookie and use it.
175                HttpCookie httpCookie = cookies.get(0);
176
177                // Handle session cookies (maxAge = -1) by passing null instead of negative Duration
178                long maxAge = httpCookie.getMaxAge();
179                Duration maxAgeDuration = (maxAge >= 0) ? Duration.ofSeconds(maxAge) : null;
180
181                ParsedSetCookieAttributes attributes = parseSetCookieAttributes(normalizedSetCookieHeaderRepresentation);
182
183                return Optional.of(ResponseCookie.with(httpCookie.getName(), httpCookie.getValue())
184                                .maxAge(maxAgeDuration)
185                                .expires(attributes.getExpires().orElse(null))
186                                .domain(httpCookie.getDomain())
187                                .httpOnly(httpCookie.isHttpOnly())
188                                .secure(httpCookie.getSecure())
189                                .path(httpCookie.getPath())
190                                .sameSite(attributes.getSameSite().orElse(null))
191                                .priority(attributes.getPriority().orElse(null))
192                                .partitioned(attributes.getPartitioned())
193                                .build());
194        }
195
196        @Nullable
197        private static String stripSetCookiePrefix(@NonNull String setCookieHeaderRepresentation) {
198                requireNonNull(setCookieHeaderRepresentation);
199
200                String trimmed = trimAggressivelyToNull(setCookieHeaderRepresentation);
201
202                if (trimmed == null)
203                        return null;
204
205                String prefix = "set-cookie:";
206
207                if (trimmed.length() >= prefix.length()
208                                && trimmed.regionMatches(true, 0, prefix, 0, prefix.length()))
209                        return trimAggressivelyToNull(trimmed.substring(prefix.length()));
210
211                return trimmed;
212        }
213
214        @NonNull
215        private static ParsedSetCookieAttributes parseSetCookieAttributes(@NonNull String setCookieHeaderRepresentation) {
216                requireNonNull(setCookieHeaderRepresentation);
217
218                ParsedSetCookieAttributes attributes = new ParsedSetCookieAttributes();
219                List<String> components = splitSetCookieHeaderRespectingQuotes(setCookieHeaderRepresentation);
220
221                if (components.size() <= 1)
222                        return attributes;
223
224                for (int i = 1; i < components.size(); i++) {
225                        String component = trimAggressivelyToNull(components.get(i));
226                        if (component == null)
227                                continue;
228
229                        int equalsIndex = component.indexOf('=');
230                        String attributeName = trimAggressivelyToNull(equalsIndex == -1 ? component : component.substring(0, equalsIndex));
231
232                        if (attributeName == null)
233                                continue;
234
235                        String attributeValue = equalsIndex == -1 ? null : trimAggressivelyToNull(component.substring(equalsIndex + 1));
236
237                        if ("samesite".equalsIgnoreCase(attributeName)) {
238                                if (attributes.sameSite == null && attributeValue != null) {
239                                        attributeValue = stripOptionalQuotes(attributeValue);
240                                        attributes.sameSite = SameSite.fromHeaderRepresentation(attributeValue).orElse(null);
241                                }
242                                continue;
243                        }
244
245                        if ("expires".equalsIgnoreCase(attributeName)) {
246                                if (attributes.expires == null && attributeValue != null) {
247                                        try {
248                                                attributeValue = stripOptionalQuotes(attributeValue);
249                                                attributes.expires = parseHttpDate(attributeValue);
250                                        } catch (IllegalArgumentException ignored) {
251                                                // Ignore malformed Expires attribute
252                                        }
253                                }
254                                continue;
255                        }
256
257                        if ("priority".equalsIgnoreCase(attributeName)) {
258                                if (attributes.priority == null && attributeValue != null) {
259                                        attributeValue = stripOptionalQuotes(attributeValue);
260                                        attributes.priority = Priority.fromHeaderRepresentation(attributeValue).orElse(null);
261                                }
262                                continue;
263                        }
264
265                        if ("partitioned".equalsIgnoreCase(attributeName))
266                                attributes.partitioned = true;
267                }
268
269                return attributes;
270        }
271
272        @NonNull
273        private static Instant parseHttpDate(@NonNull String value) {
274                requireNonNull(value);
275
276                String trimmed = trimAggressivelyToNull(value);
277
278                if (trimmed == null)
279                        throw new IllegalArgumentException("Date value cannot be blank");
280
281                for (DateTimeFormatter formatter : List.of(RFC_1123_FORMATTER, RFC_1036_PARSER, ASCTIME_PARSER)) {
282                        try {
283                                return Instant.from(formatter.parse(trimmed));
284                        } catch (Exception ignored) {
285                                // try next formatter
286                        }
287                }
288
289                throw new IllegalArgumentException(format("Unable to parse HTTP-date value '%s'", trimmed));
290        }
291
292        @NonNull
293        private static List<@NonNull String> splitSetCookieHeaderRespectingQuotes(@NonNull String headerValue) {
294                requireNonNull(headerValue);
295
296                List<String> parts = new ArrayList<>();
297                StringBuilder current = new StringBuilder(headerValue.length());
298                boolean inQuotes = false;
299                boolean escaped = false;
300
301                for (int i = 0; i < headerValue.length(); i++) {
302                        char c = headerValue.charAt(i);
303
304                        if (escaped) {
305                                current.append(c);
306                                escaped = false;
307                                continue;
308                        }
309
310                        if (c == '\\') {
311                                escaped = true;
312                                current.append(c);
313                                continue;
314                        }
315
316                        if (c == '"') {
317                                inQuotes = !inQuotes;
318                                current.append(c);
319                                continue;
320                        }
321
322                        if (c == ';' && !inQuotes) {
323                                parts.add(current.toString());
324                                current.setLength(0);
325                                continue;
326                        }
327
328                        current.append(c);
329                }
330
331                if (current.length() > 0)
332                        parts.add(current.toString());
333
334                return parts;
335        }
336
337        @NonNull
338        private static String stripOptionalQuotes(@NonNull String value) {
339                requireNonNull(value);
340
341                if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\""))
342                        return value.substring(1, value.length() - 1);
343
344                return value;
345        }
346
347        private ResponseCookie(@NonNull Builder builder) {
348                requireNonNull(builder);
349
350                this.name = builder.name;
351                this.value = builder.value;
352                this.maxAge = builder.maxAge;
353                this.expires = builder.expires;
354                this.domain = builder.domain;
355                this.path = trimAggressivelyToNull(builder.path); // Can't specify blank paths. Must be null (omitted) or start with "/"
356                this.secure = builder.secure == null ? false : builder.secure;
357                this.httpOnly = builder.httpOnly == null ? false : builder.httpOnly;
358                this.sameSite = builder.sameSite;
359                this.priority = builder.priority;
360                this.partitioned = builder.partitioned == null ? false : builder.partitioned;
361
362                // Extra validation to ensure we are not creating cookies that are illegal according to the spec
363
364                Rfc6265Utils.validateCookieName(getName());
365                Rfc6265Utils.validateCookieValue(getValue().orElse(null));
366                Rfc6265Utils.validateDomain(getDomain().orElse(null));
367                Rfc6265Utils.validatePath(getPath().orElse(null));
368
369                if (this.maxAge != null && this.maxAge.isNegative())
370                        throw new IllegalArgumentException("Max-Age must be >= 0");
371
372                if (this.sameSite == SameSite.NONE && (this.secure == null || !this.secure))
373                        throw new IllegalArgumentException(format("Specifying %s value %s.%s requires that you also set secure=true, otherwise browsers will likely discard the cookie",
374                                        ResponseCookie.class.getSimpleName(), SameSite.class.getSimpleName(), SameSite.NONE.name()));
375
376                if (this.partitioned) {
377                        if (this.secure == null || !this.secure)
378                                throw new IllegalArgumentException("Partitioned cookies require secure=true");
379
380                        String cookiePath = getPath().orElse(null);
381                        if (cookiePath == null || !cookiePath.equals("/"))
382                                throw new IllegalArgumentException("Partitioned cookies require path=\"/\"");
383
384                        if (this.sameSite != SameSite.NONE)
385                                throw new IllegalArgumentException("Partitioned cookies require SameSite=None");
386                }
387        }
388
389        /**
390         * Generates a {@code Set-Cookie} header representation of this response cookie, for example {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly}
391         * <p>
392         * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie</a> for details.
393         *
394         * @return this response cookie in {@code Set-Cookie} header format
395         */
396        @NonNull
397        public String toSetCookieHeaderRepresentation() {
398                List<String> components = new ArrayList<>(10);
399
400                components.add(format("%s=%s", getName(), getValue().orElse("")));
401
402                if (getPath().isPresent())
403                        components.add(format("Path=%s", getPath().get()));
404
405                if (getDomain().isPresent())
406                        components.add(format("Domain=%s", getDomain().get()));
407
408                long maxAge = getMaxAge().isPresent() ? getMaxAge().get().toSeconds() : -1;
409
410                if (maxAge >= 0)
411                        components.add(format("Max-Age=%d", maxAge));
412
413                if (getExpires().isPresent())
414                        components.add(format("Expires=%s", RFC_1123_FORMATTER.format(getExpires().get())));
415
416                if (getSecure())
417                        components.add("Secure");
418
419                if (getHttpOnly())
420                        components.add("HttpOnly");
421
422                if (getSameSite().isPresent())
423                        components.add(format("SameSite=%s", getSameSite().get().getHeaderRepresentation()));
424
425                if (getPriority().isPresent())
426                        components.add(format("Priority=%s", getPriority().get().getHeaderRepresentation()));
427
428                if (getPartitioned())
429                        components.add("Partitioned");
430
431                return components.stream().collect(Collectors.joining("; "));
432        }
433
434        @Override
435        public int hashCode() {
436                return Objects.hash(
437                                getName(),
438                                getValue(),
439                                getMaxAge(),
440                                getExpires(),
441                                getDomain(),
442                                getPath(),
443                                getSecure(),
444                                getHttpOnly(),
445                                getSameSite(),
446                                getPriority(),
447                                getPartitioned()
448                );
449        }
450
451        @Override
452        public boolean equals(@Nullable Object object) {
453                if (this == object)
454                        return true;
455
456                if (!(object instanceof ResponseCookie responseCookie))
457                        return false;
458
459                return Objects.equals(getName(), responseCookie.getName())
460                                && Objects.equals(getValue(), responseCookie.getValue())
461                                && Objects.equals(getMaxAge(), responseCookie.getMaxAge())
462                                && Objects.equals(getExpires(), responseCookie.getExpires())
463                                && Objects.equals(getDomain(), responseCookie.getDomain())
464                                && Objects.equals(getPath(), responseCookie.getPath())
465                                && Objects.equals(getSecure(), responseCookie.getSecure())
466                                && Objects.equals(getHttpOnly(), responseCookie.getHttpOnly())
467                                && Objects.equals(getSameSite(), responseCookie.getSameSite())
468                                && Objects.equals(getPriority(), responseCookie.getPriority())
469                                && Objects.equals(getPartitioned(), responseCookie.getPartitioned());
470        }
471
472        @Override
473        @NonNull
474        public String toString() {
475                return toSetCookieHeaderRepresentation();
476        }
477
478        /**
479         * Gets the cookie's name.
480         *
481         * @return the name of the cookie
482         */
483        @NonNull
484        public String getName() {
485                return this.name;
486        }
487
488        /**
489         * Gets the cookie's value, if present.
490         *
491         * @return the value of the cookie, or {@link Optional#empty()} if there is none
492         */
493        @NonNull
494        public Optional<String> getValue() {
495                return Optional.ofNullable(this.value);
496        }
497
498        /**
499         * Gets the cookie's {@code Max-Age} value expressed as a {@link Duration}, if present.
500         *
501         * @return the {@code Max-Age} value of the cookie, or {@link Optional#empty()} if there is none
502         */
503        @NonNull
504        public Optional<Duration> getMaxAge() {
505                return Optional.ofNullable(this.maxAge);
506        }
507
508        /**
509         * Gets the cookie's {@code Expires} value, if present.
510         *
511         * @return the {@code Expires} value of the cookie, or {@link Optional#empty()} if there is none
512         */
513        @NonNull
514        public Optional<Instant> getExpires() {
515                return Optional.ofNullable(this.expires);
516        }
517
518        /**
519         * Gets the cookie's {@code Domain} value, if present.
520         *
521         * @return the {@code Domain} value of the cookie, or {@link Optional#empty()} if there is none
522         */
523        @NonNull
524        public Optional<String> getDomain() {
525                return Optional.ofNullable(this.domain);
526        }
527
528        /**
529         * Gets the cookie's {@code Path} value, if present.
530         *
531         * @return the {@code Path} value of the cookie, or {@link Optional#empty()} if there is none
532         */
533        @NonNull
534        public Optional<String> getPath() {
535                return Optional.ofNullable(this.path);
536        }
537
538        /**
539         * Gets the cookie's {@code Secure} flag, if present.
540         *
541         * @return {@code true} if the {@code Secure} flag of the cookie is present, {@code false} otherwise
542         */
543        @NonNull
544        public Boolean getSecure() {
545                return this.secure;
546        }
547
548        /**
549         * Gets the cookie's {@code HttpOnly} flag, if present.
550         *
551         * @return {@code true} if the {@code HttpOnly} flag of the cookie is present, {@code false} otherwise
552         */
553        @NonNull
554        public Boolean getHttpOnly() {
555                return this.httpOnly;
556        }
557
558        /**
559         * Gets the cookie's {@code SameSite} value, if present.
560         *
561         * @return the {@code SameSite} value of the cookie, or {@link Optional#empty()} if there is none
562         */
563        @NonNull
564        public Optional<SameSite> getSameSite() {
565                return Optional.ofNullable(this.sameSite);
566        }
567
568        /**
569         * Gets the cookie's {@code Priority} value, if present.
570         *
571         * @return the {@code Priority} value of the cookie, or {@link Optional#empty()} if there is none
572         */
573        @NonNull
574        public Optional<Priority> getPriority() {
575                return Optional.ofNullable(this.priority);
576        }
577
578        /**
579         * Gets the cookie's {@code Partitioned} flag.
580         *
581         * @return {@code true} if the {@code Partitioned} flag is present, {@code false} otherwise
582         */
583        @NonNull
584        public Boolean getPartitioned() {
585                return this.partitioned;
586        }
587
588        /**
589         * Values which control whether a response cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks (CSRF).
590         * <p>
591         * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value</a> for details.
592         *
593         * @author <a href="https://www.revetkn.com">Mark Allen</a>
594         */
595        public enum SameSite {
596                /**
597                 * Means that the browser sends the cookie only for same-site requests, that is, requests originating from the same site that set the cookie.
598                 * If a request originates from a different domain or scheme (even with the same domain), no cookies with the {@code SameSite=Strict} attribute are sent.
599                 */
600                STRICT("Strict"),
601                /**
602                 * Means that the cookie is not sent on cross-site requests, such as on requests to load images or frames, but is sent when a user is navigating to the origin site from an external site (for example, when following a link).
603                 * This is the default behavior if the {@code SameSite} attribute is not specified.
604                 */
605                LAX("Lax"),
606                /**
607                 * Means that the browser sends the cookie with both cross-site and same-site requests.
608                 * The {@code Secure} attribute must also be set when setting this value, like so {@code SameSite=None; Secure}. If {@code Secure} is missing, an error will be logged.
609                 */
610                NONE("None");
611
612                @NonNull
613                private final String headerRepresentation;
614
615                SameSite(@NonNull String headerRepresentation) {
616                        requireNonNull(headerRepresentation);
617                        this.headerRepresentation = headerRepresentation;
618                }
619
620                /**
621                 * Returns the {@link SameSite} enum value that matches the corresponding {@code SameSite} response header value representation (one of {@code Strict}, {@code Lax}, or {@code None} - case-insensitive).
622                 *
623                 * @param headerRepresentation a case-insensitive HTTP header value - one of {@code Strict}, {@code Lax}, or {@code None}
624                 * @return the enum value that corresponds to the given the header representation, or {@link Optional#empty()} if none matches
625                 */
626                @NonNull
627                public static Optional<SameSite> fromHeaderRepresentation(@NonNull String headerRepresentation) {
628                        requireNonNull(headerRepresentation);
629
630                        headerRepresentation = headerRepresentation.trim();
631
632                        for (SameSite sameSite : values())
633                                if (headerRepresentation.equalsIgnoreCase(sameSite.getHeaderRepresentation()))
634                                        return Optional.of(sameSite);
635
636                        return Optional.empty();
637                }
638
639                /**
640                 * The HTTP header value that corresponds to this enum value - one of {@code Strict}, {@code Lax}, or {@code None}.
641                 *
642                 * @return the HTTP header value for this enum
643                 */
644                @NonNull
645                public String getHeaderRepresentation() {
646                        return this.headerRepresentation;
647                }
648        }
649
650        /**
651         * Represents the {@code Priority} response cookie attribute.
652         */
653        public enum Priority {
654                LOW("Low"),
655                MEDIUM("Medium"),
656                HIGH("High");
657
658                @NonNull
659                private final String headerRepresentation;
660
661                Priority(@NonNull String headerRepresentation) {
662                        requireNonNull(headerRepresentation);
663                        this.headerRepresentation = headerRepresentation;
664                }
665
666                @NonNull
667                public String getHeaderRepresentation() {
668                        return this.headerRepresentation;
669                }
670
671                @NonNull
672                public static Optional<Priority> fromHeaderRepresentation(@NonNull String headerRepresentation) {
673                        requireNonNull(headerRepresentation);
674
675                        String normalized = headerRepresentation.trim();
676
677                        for (Priority priority : values())
678                                if (normalized.equalsIgnoreCase(priority.getHeaderRepresentation()))
679                                        return Optional.of(priority);
680
681                        return Optional.empty();
682                }
683        }
684
685        /**
686         * Builder used to construct instances of {@link ResponseCookie} via {@link ResponseCookie#withName(String)}
687         * or {@link ResponseCookie#with(String, String)}.
688         * <p>
689         * This class is intended for use by a single thread.
690         *
691         * @author <a href="https://www.revetkn.com">Mark Allen</a>
692         */
693        @NotThreadSafe
694        public static final class Builder {
695                @NonNull
696                private String name;
697                @Nullable
698                private String value;
699                @Nullable
700                private Duration maxAge;
701                @Nullable
702                private Instant expires;
703                @Nullable
704                private String domain;
705                @Nullable
706                private String path;
707                @Nullable
708                private Boolean secure;
709                @Nullable
710                private Boolean httpOnly;
711                @Nullable
712                private SameSite sameSite;
713                @Nullable
714                private Priority priority;
715                @Nullable
716                private Boolean partitioned;
717
718                protected Builder(@NonNull String name) {
719                        requireNonNull(name);
720                        this.name = name;
721                }
722
723                protected Builder(@NonNull String name,
724                                                                                        @Nullable String value) {
725                        requireNonNull(name);
726                        this.name = name;
727                        this.value = value;
728                }
729
730                @NonNull
731                public Builder name(@NonNull String name) {
732                        requireNonNull(name);
733                        this.name = name;
734                        return this;
735                }
736
737                @NonNull
738                public Builder value(@Nullable String value) {
739                        this.value = value;
740                        return this;
741                }
742
743                @NonNull
744                public Builder maxAge(@Nullable Duration maxAge) {
745                        this.maxAge = maxAge;
746                        return this;
747                }
748
749                @NonNull
750                public Builder expires(@Nullable Instant expires) {
751                        this.expires = expires;
752                        return this;
753                }
754
755                @NonNull
756                public Builder domain(@Nullable String domain) {
757                        this.domain = domain;
758                        return this;
759                }
760
761                @NonNull
762                public Builder path(@Nullable String path) {
763                        this.path = path;
764                        return this;
765                }
766
767                @NonNull
768                public Builder secure(@Nullable Boolean secure) {
769                        this.secure = secure;
770                        return this;
771                }
772
773                @NonNull
774                public Builder httpOnly(@Nullable Boolean httpOnly) {
775                        this.httpOnly = httpOnly;
776                        return this;
777                }
778
779                @NonNull
780                public Builder sameSite(@Nullable SameSite sameSite) {
781                        this.sameSite = sameSite;
782                        return this;
783                }
784
785                @NonNull
786                public Builder priority(@Nullable Priority priority) {
787                        this.priority = priority;
788                        return this;
789                }
790
791                @NonNull
792                public Builder partitioned(@Nullable Boolean partitioned) {
793                        this.partitioned = partitioned;
794                        return this;
795                }
796
797                @NonNull
798                public ResponseCookie build() {
799                        return new ResponseCookie(this);
800                }
801        }
802
803        /**
804         * Builder used to copy instances of {@link ResponseCookie} via {@link ResponseCookie#copy()}.
805         * <p>
806         * This class is intended for use by a single thread.
807         *
808         * @author <a href="https://www.revetkn.com">Mark Allen</a>
809         */
810        @NotThreadSafe
811        public static final class Copier {
812                @NonNull
813                private final Builder builder;
814
815                Copier(@NonNull ResponseCookie responseCookie) {
816                        requireNonNull(responseCookie);
817
818                        this.builder = new Builder(responseCookie.getName())
819                                        .value(responseCookie.getValue().orElse(null))
820                                        .maxAge(responseCookie.getMaxAge().orElse(null))
821                                        .expires(responseCookie.getExpires().orElse(null))
822                                        .domain(responseCookie.getDomain().orElse(null))
823                                        .path(responseCookie.getPath().orElse(null))
824                                        .secure(responseCookie.getSecure())
825                                        .httpOnly(responseCookie.getHttpOnly())
826                                        .sameSite(responseCookie.getSameSite().orElse(null))
827                                        .priority(responseCookie.getPriority().orElse(null))
828                                        .partitioned(responseCookie.getPartitioned());
829                }
830
831                @NonNull
832                public Copier name(@NonNull String name) {
833                        requireNonNull(name);
834                        this.builder.name(name);
835                        return this;
836                }
837
838                @NonNull
839                public Copier value(@Nullable String value) {
840                        this.builder.value(value);
841                        return this;
842                }
843
844                @NonNull
845                public Copier maxAge(@Nullable Duration maxAge) {
846                        this.builder.maxAge(maxAge);
847                        return this;
848                }
849
850                @NonNull
851                public Copier expires(@Nullable Instant expires) {
852                        this.builder.expires(expires);
853                        return this;
854                }
855
856                @NonNull
857                public Copier domain(@Nullable String domain) {
858                        this.builder.domain(domain);
859                        return this;
860                }
861
862                @NonNull
863                public Copier path(@Nullable String path) {
864                        this.builder.path(path);
865                        return this;
866                }
867
868                @NonNull
869                public Copier secure(@Nullable Boolean secure) {
870                        this.builder.secure(secure);
871                        return this;
872                }
873
874                @NonNull
875                public Copier httpOnly(@Nullable Boolean httpOnly) {
876                        this.builder.httpOnly(httpOnly);
877                        return this;
878                }
879
880                @NonNull
881                public Copier sameSite(@Nullable SameSite sameSite) {
882                        this.builder.sameSite(sameSite);
883                        return this;
884                }
885
886                @NonNull
887                public Copier priority(@Nullable Priority priority) {
888                        this.builder.priority(priority);
889                        return this;
890                }
891
892                @NonNull
893                public Copier partitioned(@Nullable Boolean partitioned) {
894                        this.builder.partitioned(partitioned);
895                        return this;
896                }
897
898                @NonNull
899                public ResponseCookie finish() {
900                        return this.builder.build();
901                }
902        }
903
904        @ThreadSafe
905        private static final class ParsedSetCookieAttributes {
906                @Nullable
907                private Instant expires;
908                @Nullable
909                private SameSite sameSite;
910                @Nullable
911                private Priority priority;
912                @NonNull
913                private Boolean partitioned = false;
914
915                @NonNull
916                private Optional<Instant> getExpires() {
917                        return Optional.ofNullable(this.expires);
918                }
919
920                @NonNull
921                private Optional<SameSite> getSameSite() {
922                        return Optional.ofNullable(this.sameSite);
923                }
924
925                @NonNull
926                private Optional<Priority> getPriority() {
927                        return Optional.ofNullable(this.priority);
928                }
929
930                @NonNull
931                private Boolean getPartitioned() {
932                        return this.partitioned;
933                }
934        }
935
936        /**
937         * See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/http/ResponseCookie.java
938         * <p>
939         * Copyright 2002-2023 the original author or authors.
940         * <p>
941         * Licensed under the Apache License, Version 2.0 (the "License");
942         * you may not use this file except in compliance with the License.
943         * You may obtain a copy of the License at
944         * <p>
945         * https://www.apache.org/licenses/LICENSE-2.0
946         * <p>
947         * Unless required by applicable law or agreed to in writing, software
948         * distributed under the License is distributed on an "AS IS" BASIS,
949         * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
950         * See the License for the specific language governing permissions and
951         * limitations under the License.
952         * <p>
953         *
954         * @author Rossen Stoyanchev
955         * @author Brian Clozel
956         */
957        @ThreadSafe
958        private static class Rfc6265Utils {
959                @NonNull
960                private static final String SEPARATOR_CHARS = new String(new char[]{
961                                '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' '
962                });
963
964                @NonNull
965                private static final String DOMAIN_CHARS =
966                                "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-";
967
968                public static void validateCookieName(@NonNull String name) {
969                        requireNonNull(name);
970
971                        for (int i = 0; i < name.length(); i++) {
972                                char c = name.charAt(i);
973                                // CTL = <US-ASCII control chars (octets 0 - 31) and DEL (127)>
974                                if (c <= 0x1F || c == 0x7F) {
975                                        throw new IllegalArgumentException(
976                                                        name + ": RFC2616 token cannot have control chars");
977                                }
978                                if (SEPARATOR_CHARS.indexOf(c) >= 0) {
979                                        throw new IllegalArgumentException(
980                                                        name + ": RFC2616 token cannot have separator chars such as '" + c + "'");
981                                }
982                                if (c >= 0x80) {
983                                        throw new IllegalArgumentException(
984                                                        name + ": RFC2616 token can only have US-ASCII: 0x" + Integer.toHexString(c));
985                                }
986                        }
987                }
988
989                public static void validateCookieValue(@Nullable String value) {
990                        if (value == null) {
991                                return;
992                        }
993                        int start = 0;
994                        int end = value.length();
995                        if (end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"') {
996                                start = 1;
997                                end--;
998                        }
999                        for (int i = start; i < end; i++) {
1000                                char c = value.charAt(i);
1001                                if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) {
1002                                        throw new IllegalArgumentException(
1003                                                        "RFC2616 cookie value cannot have '" + c + "'");
1004                                }
1005                                if (c >= 0x80) {
1006                                        throw new IllegalArgumentException(
1007                                                        "RFC2616 cookie value can only have US-ASCII chars: 0x" + Integer.toHexString(c));
1008                                }
1009                        }
1010                }
1011
1012                public static void validateDomain(@Nullable String domain) {
1013                        if (domain == null || domain.trim().length() == 0) {
1014                                return;
1015                        }
1016                        int char1 = domain.charAt(0);
1017                        int charN = domain.charAt(domain.length() - 1);
1018                        if (char1 == '-' || charN == '.' || charN == '-') {
1019                                throw new IllegalArgumentException("Invalid first/last char in cookie domain: " + domain);
1020                        }
1021                        for (int i = 0, c = -1; i < domain.length(); i++) {
1022                                int p = c;
1023                                c = domain.charAt(i);
1024                                if (DOMAIN_CHARS.indexOf(c) == -1 || (p == '.' && (c == '.' || c == '-')) || (p == '-' && c == '.')) {
1025                                        throw new IllegalArgumentException(domain + ": invalid cookie domain char '" + (char) c + "'");
1026                                }
1027                        }
1028                }
1029
1030                public static void validatePath(@Nullable String path) {
1031                        if (path == null)
1032                                return;
1033
1034                        // Check for path traversal attempts
1035                        if (path.contains("..")) {
1036                                throw new IllegalArgumentException(
1037                                                "Cookie path must not contain '..' segments (path traversal): " + path
1038                                );
1039                        }
1040
1041                        // Check for query strings
1042                        if (path.contains("?")) {
1043                                throw new IllegalArgumentException(
1044                                                "Cookie path must not contain query strings: " + path
1045                                );
1046                        }
1047
1048                        // Check for fragments
1049                        if (path.contains("#")) {
1050                                throw new IllegalArgumentException(
1051                                                "Cookie path must not contain fragments: " + path
1052                                );
1053                        }
1054
1055                        // Check for backslashes (Windows-style paths)
1056                        if (path.contains("\\")) {
1057                                throw new IllegalArgumentException(
1058                                                "Cookie path must not contain backslashes: " + path
1059                                );
1060                        }
1061
1062                        // Check for URL-encoded dots (potential bypass attempt)
1063                        if (path.contains("%2E") || path.contains("%2e")) {
1064                                throw new IllegalArgumentException(
1065                                                "Cookie path must not contain encoded dots: " + path
1066                                );
1067                        }
1068
1069                        // Must start with forward slash
1070                        if (!path.startsWith("/")) {
1071                                throw new IllegalArgumentException(
1072                                                "Cookie path must start with '/': " + path
1073                                );
1074                        }
1075
1076                        // Check for null bytes
1077                        if (path.contains("\0") || path.contains("%00")) {
1078                                throw new IllegalArgumentException(
1079                                                "Cookie path must not contain null bytes: " + path
1080                                );
1081                        }
1082
1083                        for (int i = 0; i < path.length(); i++) {
1084                                char c = path.charAt(i);
1085                                if (c < 0x20 || c > 0x7E || c == ';') {
1086                                        throw new IllegalArgumentException(path + ": Invalid cookie path char '" + c + "'");
1087                                }
1088                        }
1089                }
1090        }
1091}