001/*
002 * Copyright 2022-2025 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 javax.annotation.Nonnull;
020import javax.annotation.Nullable;
021import javax.annotation.concurrent.NotThreadSafe;
022import javax.annotation.concurrent.ThreadSafe;
023import java.net.HttpCookie;
024import java.time.Duration;
025import java.util.ArrayList;
026import java.util.List;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.stream.Collectors;
030
031import static com.soklet.Utilities.trimAggressivelyToNull;
032import static java.lang.String.format;
033import static java.util.Objects.requireNonNull;
034
035/**
036 * HTTP "response" Cookie representation which supports {@code Set-Cookie} header encoding.
037 * <p>
038 * Threadsafe instances can be acquired via these builder factory methods:
039 * <ul>
040 *   <li>{@link #with(String, String)} (builder primed with name and value)</li>
041 *   <li>{@link #withName(String)} (builder primed with name only)</li>
042 * </ul>
043 *
044 * @author <a href="https://www.revetkn.com">Mark Allen</a>
045 */
046@ThreadSafe
047public final class ResponseCookie {
048        @Nonnull
049        private final String name;
050        @Nullable
051        private final String value;
052        @Nullable
053        private final Duration maxAge;
054        @Nullable
055        private final String domain;
056        @Nullable
057        private final String path;
058        @Nonnull
059        private final Boolean secure;
060        @Nonnull
061        private final Boolean httpOnly;
062        @Nullable
063        private final SameSite sameSite;
064
065        /**
066         * Acquires a builder for {@link ResponseCookie} instances.
067         *
068         * @param name  the cookie name
069         * @param value the cookie value
070         * @return the builder
071         */
072        @Nonnull
073        public static Builder with(@Nonnull String name,
074                                                                                                                 @Nullable String value) {
075                requireNonNull(name);
076                return new Builder(name, value);
077        }
078
079        /**
080         * Acquires a builder for {@link ResponseCookie} instances without specifying the cookie's value.
081         *
082         * @param name the cookie name
083         * @return the builder
084         */
085        @Nonnull
086        public static Builder withName(@Nonnull String name) {
087                requireNonNull(name);
088                return new Builder(name);
089        }
090
091        /**
092         * Vends a mutable copier seeded with this instance's data, suitable for building new instances.
093         *
094         * @return a copier for this instance
095         */
096        @Nonnull
097        public Copier copy() {
098                return new Copier(this);
099        }
100
101        /**
102         * Given a {@code Set-Cookie} header representation, provide a {@link ResponseCookie} that matches it.
103         * <p>
104         * An example of a {@code Set-Cookie} header representation is {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly}
105         * <p>
106         * 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.
107         * <p>
108         * 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.
109         *
110         * @param setCookieHeaderRepresentation a {@code Set-Cookie} header representation
111         * @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
112         * @throws IllegalArgumentException if the {@code Set-Cookie} header representation is malformed
113         */
114        @Nonnull
115        public static Optional<ResponseCookie> fromSetCookieHeaderRepresentation(@Nullable String setCookieHeaderRepresentation) {
116                setCookieHeaderRepresentation = setCookieHeaderRepresentation == null ? null : setCookieHeaderRepresentation.trim();
117
118                if (setCookieHeaderRepresentation == null || setCookieHeaderRepresentation.length() == 0)
119                        return Optional.empty();
120
121                List<HttpCookie> cookies = HttpCookie.parse(setCookieHeaderRepresentation);
122
123                if (cookies.size() == 0)
124                        return Optional.empty();
125
126                // Technically OK per the spec to "fold" multiple cookie name/value pairs into the same header but this is
127                // unusual, and we don't support it here.  Pick the first cookie and use it.
128                HttpCookie httpCookie = cookies.get(0);
129
130                // Handle session cookies (maxAge = -1) by passing null instead of negative Duration
131                long maxAge = httpCookie.getMaxAge();
132                Duration maxAgeDuration = (maxAge >= 0) ? Duration.ofSeconds(maxAge) : null;
133
134                return Optional.of(ResponseCookie.with(httpCookie.getName(), httpCookie.getValue())
135                                .maxAge(maxAgeDuration)
136                                .domain(httpCookie.getDomain())
137                                .httpOnly(httpCookie.isHttpOnly())
138                                .secure(httpCookie.getSecure())
139                                .path(httpCookie.getPath())
140                                .build());
141        }
142
143        private ResponseCookie(@Nonnull Builder builder) {
144                requireNonNull(builder);
145
146                this.name = builder.name;
147                this.value = builder.value;
148                this.maxAge = builder.maxAge;
149                this.domain = builder.domain;
150                this.path = trimAggressivelyToNull(builder.path); // Can't specify blank paths. Must be null (omitted) or start with "/"
151                this.secure = builder.secure == null ? false : builder.secure;
152                this.httpOnly = builder.httpOnly == null ? false : builder.httpOnly;
153                this.sameSite = builder.sameSite;
154
155                // Extra validation to ensure we are not creating cookies that are illegal according to the spec
156
157                Rfc6265Utils.validateCookieName(getName());
158                Rfc6265Utils.validateCookieValue(getValue().orElse(null));
159                Rfc6265Utils.validateDomain(getDomain().orElse(null));
160                Rfc6265Utils.validatePath(getPath().orElse(null));
161
162                if (this.sameSite == SameSite.NONE && (this.secure == null || !this.secure))
163                        throw new IllegalArgumentException(format("Specifying %s value %s.%s requires that you also set secure=true, otherwise browsers will likely discard the cookie",
164                                        ResponseCookie.class.getSimpleName(), SameSite.class.getSimpleName(), SameSite.NONE.name()));
165        }
166
167        /**
168         * 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}
169         * <p>
170         * 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.
171         *
172         * @return this response cookie in {@code Set-Cookie} header format
173         */
174        @Nonnull
175        public String toSetCookieHeaderRepresentation() {
176                List<String> components = new ArrayList<>(8);
177
178                components.add(format("%s=%s", getName(), getValue().orElse("")));
179
180                if (getPath().isPresent())
181                        components.add(format("Path=%s", getPath().get()));
182
183                if (getDomain().isPresent())
184                        components.add(format("Domain=%s", getDomain().get()));
185
186                long maxAge = getMaxAge().isPresent() ? getMaxAge().get().toSeconds() : -1;
187
188                if (maxAge >= 0)
189                        components.add(format("Max-Age=%d", maxAge));
190
191                if (getSecure())
192                        components.add("Secure");
193
194                if (getHttpOnly())
195                        components.add("HttpOnly");
196
197                if (getSameSite().isPresent())
198                        components.add(format("SameSite=%s", getSameSite().get().getHeaderRepresentation()));
199
200                return components.stream().collect(Collectors.joining("; "));
201        }
202
203        @Override
204        public int hashCode() {
205                return Objects.hash(
206                                getName(),
207                                getValue(),
208                                getMaxAge(),
209                                getDomain(),
210                                getPath(),
211                                getSecure(),
212                                getHttpOnly(),
213                                getSameSite()
214                );
215        }
216
217        @Override
218        public boolean equals(@Nullable Object object) {
219                if (this == object)
220                        return true;
221
222                if (!(object instanceof ResponseCookie responseCookie))
223                        return false;
224
225                return Objects.equals(getName(), responseCookie.getName())
226                                && Objects.equals(getValue(), responseCookie.getValue())
227                                && Objects.equals(getMaxAge(), responseCookie.getMaxAge())
228                                && Objects.equals(getDomain(), responseCookie.getDomain())
229                                && Objects.equals(getPath(), responseCookie.getPath())
230                                && Objects.equals(getSecure(), responseCookie.getSecure())
231                                && Objects.equals(getHttpOnly(), responseCookie.getHttpOnly())
232                                && Objects.equals(getSameSite(), responseCookie.getSameSite());
233        }
234
235        @Override
236        @Nonnull
237        public String toString() {
238                return toSetCookieHeaderRepresentation();
239        }
240
241        /**
242         * Gets the cookie's name.
243         *
244         * @return the name of the cookie
245         */
246        @Nonnull
247        public String getName() {
248                return this.name;
249        }
250
251        /**
252         * Gets the cookie's value, if present.
253         *
254         * @return the value of the cookie, or {@link Optional#empty()} if there is none
255         */
256        @Nonnull
257        public Optional<String> getValue() {
258                return Optional.ofNullable(this.value);
259        }
260
261        /**
262         * Gets the cookie's {@code Max-Age} value expressed as a {@link Duration}, if present.
263         *
264         * @return the {@code Max-Age} value of the cookie, or {@link Optional#empty()} if there is none
265         */
266        @Nonnull
267        public Optional<Duration> getMaxAge() {
268                return Optional.ofNullable(this.maxAge);
269        }
270
271        /**
272         * Gets the cookie's {@code Domain} value, if present.
273         *
274         * @return the {@code Domain} value of the cookie, or {@link Optional#empty()} if there is none
275         */
276        @Nonnull
277        public Optional<String> getDomain() {
278                return Optional.ofNullable(this.domain);
279        }
280
281        /**
282         * Gets the cookie's {@code Path} value, if present.
283         *
284         * @return the {@code Path} value of the cookie, or {@link Optional#empty()} if there is none
285         */
286        @Nonnull
287        public Optional<String> getPath() {
288                return Optional.ofNullable(this.path);
289        }
290
291        /**
292         * Gets the cookie's {@code Secure} flag, if present.
293         *
294         * @return {@code true} if the {@code Secure} flag of the cookie is present, {@code false} otherwise
295         */
296        @Nonnull
297        public Boolean getSecure() {
298                return this.secure;
299        }
300
301        /**
302         * Gets the cookie's {@code HttpOnly} flag, if present.
303         *
304         * @return {@code true} if the {@code HttpOnly} flag of the cookie is present, {@code false} otherwise
305         */
306        @Nonnull
307        public Boolean getHttpOnly() {
308                return this.httpOnly;
309        }
310
311        /**
312         * Gets the cookie's {@code SameSite} value, if present.
313         *
314         * @return the {@code SameSite} value of the cookie, or {@link Optional#empty()} if there is none
315         */
316        @Nonnull
317        public Optional<SameSite> getSameSite() {
318                return Optional.ofNullable(this.sameSite);
319        }
320
321        /**
322         * Values which control whether a response cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks (CSRF).
323         * <p>
324         * 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.
325         *
326         * @author <a href="https://www.revetkn.com">Mark Allen</a>
327         */
328        public enum SameSite {
329                /**
330                 * Means that the browser sends the cookie only for same-site requests, that is, requests originating from the same site that set the cookie.
331                 * 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.
332                 */
333                STRICT("Strict"),
334                /**
335                 * 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).
336                 * This is the default behavior if the {@code SameSite} attribute is not specified.
337                 */
338                LAX("Lax"),
339                /**
340                 * Means that the browser sends the cookie with both cross-site and same-site requests.
341                 * 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.
342                 */
343                NONE("None");
344
345                @Nonnull
346                private final String headerRepresentation;
347
348                SameSite(@Nonnull String headerRepresentation) {
349                        requireNonNull(headerRepresentation);
350                        this.headerRepresentation = headerRepresentation;
351                }
352
353                /**
354                 * 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).
355                 *
356                 * @param headerRepresentation a case-insensitive HTTP header value - one of {@code Strict}, {@code Lax}, or {@code None}
357                 * @return the enum value that corresponds to the given the header representation, or {@link Optional#empty()} if none matches
358                 */
359                @Nonnull
360                public static Optional<SameSite> fromHeaderRepresentation(@Nonnull String headerRepresentation) {
361                        requireNonNull(headerRepresentation);
362
363                        headerRepresentation = headerRepresentation.trim();
364
365                        for (SameSite sameSite : values())
366                                if (headerRepresentation.equalsIgnoreCase(sameSite.getHeaderRepresentation()))
367                                        return Optional.of(sameSite);
368
369                        return Optional.empty();
370                }
371
372                /**
373                 * The HTTP header value that corresponds to this enum value - one of {@code Strict}, {@code Lax}, or {@code None}.
374                 *
375                 * @return the HTTP header value for this enum
376                 */
377                @Nonnull
378                public String getHeaderRepresentation() {
379                        return this.headerRepresentation;
380                }
381        }
382
383        /**
384         * Builder used to construct instances of {@link ResponseCookie} via {@link ResponseCookie#withName(String)}
385         * or {@link ResponseCookie#with(String, String)}.
386         * <p>
387         * This class is intended for use by a single thread.
388         *
389         * @author <a href="https://www.revetkn.com">Mark Allen</a>
390         */
391        @NotThreadSafe
392        public static final class Builder {
393                @Nonnull
394                private String name;
395                @Nullable
396                private String value;
397                @Nullable
398                private Duration maxAge;
399                @Nullable
400                private String domain;
401                @Nullable
402                private String path;
403                @Nullable
404                private Boolean secure;
405                @Nullable
406                private Boolean httpOnly;
407                @Nullable
408                private SameSite sameSite;
409
410                protected Builder(@Nonnull String name) {
411                        requireNonNull(name);
412                        this.name = name;
413                }
414
415                protected Builder(@Nonnull String name,
416                                                                                        @Nullable String value) {
417                        requireNonNull(name);
418                        this.name = name;
419                        this.value = value;
420                }
421
422                @Nonnull
423                public Builder name(@Nonnull String name) {
424                        requireNonNull(name);
425                        this.name = name;
426                        return this;
427                }
428
429                @Nonnull
430                public Builder value(@Nullable String value) {
431                        this.value = value;
432                        return this;
433                }
434
435                @Nonnull
436                public Builder maxAge(@Nullable Duration maxAge) {
437                        this.maxAge = maxAge;
438                        return this;
439                }
440
441                @Nonnull
442                public Builder domain(@Nullable String domain) {
443                        this.domain = domain;
444                        return this;
445                }
446
447                @Nonnull
448                public Builder path(@Nullable String path) {
449                        this.path = path;
450                        return this;
451                }
452
453                @Nonnull
454                public Builder secure(@Nullable Boolean secure) {
455                        this.secure = secure;
456                        return this;
457                }
458
459                @Nonnull
460                public Builder httpOnly(@Nullable Boolean httpOnly) {
461                        this.httpOnly = httpOnly;
462                        return this;
463                }
464
465                @Nonnull
466                public Builder sameSite(@Nullable SameSite sameSite) {
467                        this.sameSite = sameSite;
468                        return this;
469                }
470
471                @Nonnull
472                public ResponseCookie build() {
473                        return new ResponseCookie(this);
474                }
475        }
476
477        /**
478         * Builder used to copy instances of {@link ResponseCookie} via {@link ResponseCookie#copy()}.
479         * <p>
480         * This class is intended for use by a single thread.
481         *
482         * @author <a href="https://www.revetkn.com">Mark Allen</a>
483         */
484        @NotThreadSafe
485        public static final class Copier {
486                @Nonnull
487                private final Builder builder;
488
489                Copier(@Nonnull ResponseCookie responseCookie) {
490                        requireNonNull(responseCookie);
491
492                        this.builder = new Builder(responseCookie.getName())
493                                        .value(responseCookie.getValue().orElse(null))
494                                        .maxAge(responseCookie.getMaxAge().orElse(null))
495                                        .domain(responseCookie.getDomain().orElse(null))
496                                        .path(responseCookie.getPath().orElse(null))
497                                        .secure(responseCookie.getSecure())
498                                        .httpOnly(responseCookie.getHttpOnly())
499                                        .sameSite(responseCookie.getSameSite().orElse(null));
500                }
501
502                @Nonnull
503                public Copier name(@Nonnull String name) {
504                        requireNonNull(name);
505                        this.builder.name(name);
506                        return this;
507                }
508
509                @Nonnull
510                public Copier value(@Nullable String value) {
511                        this.builder.value(value);
512                        return this;
513                }
514
515                @Nonnull
516                public Copier maxAge(@Nullable Duration maxAge) {
517                        this.builder.maxAge(maxAge);
518                        return this;
519                }
520
521                @Nonnull
522                public Copier domain(@Nullable String domain) {
523                        this.builder.domain(domain);
524                        return this;
525                }
526
527                @Nonnull
528                public Copier path(@Nullable String path) {
529                        this.builder.path(path);
530                        return this;
531                }
532
533                @Nonnull
534                public Copier secure(@Nullable Boolean secure) {
535                        this.builder.secure(secure);
536                        return this;
537                }
538
539                @Nonnull
540                public Copier httpOnly(@Nullable Boolean httpOnly) {
541                        this.builder.httpOnly(httpOnly);
542                        return this;
543                }
544
545                @Nonnull
546                public Copier sameSite(@Nullable SameSite sameSite) {
547                        this.builder.sameSite(sameSite);
548                        return this;
549                }
550
551                @Nonnull
552                public ResponseCookie finish() {
553                        return this.builder.build();
554                }
555        }
556
557        /**
558         * See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/http/ResponseCookie.java
559         * <p>
560         * Copyright 2002-2023 the original author or authors.
561         * <p>
562         * Licensed under the Apache License, Version 2.0 (the "License");
563         * you may not use this file except in compliance with the License.
564         * You may obtain a copy of the License at
565         * <p>
566         * https://www.apache.org/licenses/LICENSE-2.0
567         * <p>
568         * Unless required by applicable law or agreed to in writing, software
569         * distributed under the License is distributed on an "AS IS" BASIS,
570         * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
571         * See the License for the specific language governing permissions and
572         * limitations under the License.
573         * <p>
574         *
575         * @author Rossen Stoyanchev
576         * @author Brian Clozel
577         */
578        @ThreadSafe
579        private static class Rfc6265Utils {
580                @Nonnull
581                private static final String SEPARATOR_CHARS = new String(new char[]{
582                                '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' '
583                });
584
585                @Nonnull
586                private static final String DOMAIN_CHARS =
587                                "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-";
588
589                public static void validateCookieName(@Nonnull String name) {
590                        requireNonNull(name);
591
592                        for (int i = 0; i < name.length(); i++) {
593                                char c = name.charAt(i);
594                                // CTL = <US-ASCII control chars (octets 0 - 31) and DEL (127)>
595                                if (c <= 0x1F || c == 0x7F) {
596                                        throw new IllegalArgumentException(
597                                                        name + ": RFC2616 token cannot have control chars");
598                                }
599                                if (SEPARATOR_CHARS.indexOf(c) >= 0) {
600                                        throw new IllegalArgumentException(
601                                                        name + ": RFC2616 token cannot have separator chars such as '" + c + "'");
602                                }
603                                if (c >= 0x80) {
604                                        throw new IllegalArgumentException(
605                                                        name + ": RFC2616 token can only have US-ASCII: 0x" + Integer.toHexString(c));
606                                }
607                        }
608                }
609
610                public static void validateCookieValue(@Nullable String value) {
611                        if (value == null) {
612                                return;
613                        }
614                        int start = 0;
615                        int end = value.length();
616                        if (end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"') {
617                                start = 1;
618                                end--;
619                        }
620                        for (int i = start; i < end; i++) {
621                                char c = value.charAt(i);
622                                if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) {
623                                        throw new IllegalArgumentException(
624                                                        "RFC2616 cookie value cannot have '" + c + "'");
625                                }
626                                if (c >= 0x80) {
627                                        throw new IllegalArgumentException(
628                                                        "RFC2616 cookie value can only have US-ASCII chars: 0x" + Integer.toHexString(c));
629                                }
630                        }
631                }
632
633                public static void validateDomain(@Nullable String domain) {
634                        if (domain == null || domain.trim().length() == 0) {
635                                return;
636                        }
637                        int char1 = domain.charAt(0);
638                        int charN = domain.charAt(domain.length() - 1);
639                        if (char1 == '-' || charN == '.' || charN == '-') {
640                                throw new IllegalArgumentException("Invalid first/last char in cookie domain: " + domain);
641                        }
642                        for (int i = 0, c = -1; i < domain.length(); i++) {
643                                int p = c;
644                                c = domain.charAt(i);
645                                if (DOMAIN_CHARS.indexOf(c) == -1 || (p == '.' && (c == '.' || c == '-')) || (p == '-' && c == '.')) {
646                                        throw new IllegalArgumentException(domain + ": invalid cookie domain char '" + (char) c + "'");
647                                }
648                        }
649                }
650
651                public static void validatePath(@Nullable String path) {
652                        if (path == null)
653                                return;
654
655                        // Check for path traversal attempts
656                        if (path.contains("..")) {
657                                throw new IllegalArgumentException(
658                                                "Cookie path must not contain '..' segments (path traversal): " + path
659                                );
660                        }
661
662                        // Check for query strings
663                        if (path.contains("?")) {
664                                throw new IllegalArgumentException(
665                                                "Cookie path must not contain query strings: " + path
666                                );
667                        }
668
669                        // Check for fragments
670                        if (path.contains("#")) {
671                                throw new IllegalArgumentException(
672                                                "Cookie path must not contain fragments: " + path
673                                );
674                        }
675
676                        // Check for backslashes (Windows-style paths)
677                        if (path.contains("\\")) {
678                                throw new IllegalArgumentException(
679                                                "Cookie path must not contain backslashes: " + path
680                                );
681                        }
682
683                        // Check for URL-encoded dots (potential bypass attempt)
684                        if (path.contains("%2E") || path.contains("%2e")) {
685                                throw new IllegalArgumentException(
686                                                "Cookie path must not contain encoded dots: " + path
687                                );
688                        }
689
690                        // Must start with forward slash
691                        if (!path.startsWith("/")) {
692                                throw new IllegalArgumentException(
693                                                "Cookie path must start with '/': " + path
694                                );
695                        }
696
697                        // Check for null bytes
698                        if (path.contains("\0") || path.contains("%00")) {
699                                throw new IllegalArgumentException(
700                                                "Cookie path must not contain null bytes: " + path
701                                );
702                        }
703
704                        for (int i = 0; i < path.length(); i++) {
705                                char c = path.charAt(i);
706                                if (c < 0x20 || c > 0x7E || c == ';') {
707                                        throw new IllegalArgumentException(path + ": Invalid cookie path char '" + c + "'");
708                                }
709                        }
710                }
711        }
712}