1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.net.captiveportal;
18 
19 import static android.net.captiveportal.CaptivePortalProbeResult.PORTAL_CODE;
20 import static android.net.captiveportal.CaptivePortalProbeResult.SUCCESS_CODE;
21 
22 import android.annotation.NonNull;
23 import android.annotation.Nullable;
24 import android.text.TextUtils;
25 import android.util.Log;
26 
27 import java.net.MalformedURLException;
28 import java.net.URL;
29 import java.text.ParseException;
30 import java.util.ArrayList;
31 import java.util.List;
32 import java.util.regex.Pattern;
33 import java.util.regex.PatternSyntaxException;
34 
35 /** @hide */
36 public abstract class CaptivePortalProbeSpec {
37     public static final String HTTP_LOCATION_HEADER_NAME = "Location";
38 
39     private static final String TAG = CaptivePortalProbeSpec.class.getSimpleName();
40     private static final String REGEX_SEPARATOR = "@@/@@";
41     private static final String SPEC_SEPARATOR = "@@,@@";
42 
43     private final String mEncodedSpec;
44     private final URL mUrl;
45 
CaptivePortalProbeSpec(String encodedSpec, URL url)46     CaptivePortalProbeSpec(String encodedSpec, URL url) {
47         mEncodedSpec = encodedSpec;
48         mUrl = url;
49     }
50 
51     /**
52      * Parse a {@link CaptivePortalProbeSpec} from a {@link String}.
53      *
54      * <p>The valid format is a URL followed by two regular expressions, each separated by "@@/@@".
55      * @throws MalformedURLException The URL has invalid format for {@link URL#URL(String)}.
56      * @throws ParseException The string is empty, does not match the above format, or a regular
57      * expression is invalid for {@link Pattern#compile(String)}.
58      */
59     @NonNull
parseSpec(String spec)60     public static CaptivePortalProbeSpec parseSpec(String spec) throws ParseException,
61             MalformedURLException {
62         if (TextUtils.isEmpty(spec)) {
63             throw new ParseException("Empty probe spec", 0 /* errorOffset */);
64         }
65 
66         String[] splits = TextUtils.split(spec, REGEX_SEPARATOR);
67         if (splits.length != 3) {
68             throw new ParseException("Probe spec does not have 3 parts", 0 /* errorOffset */);
69         }
70 
71         final int statusRegexPos = splits[0].length() + REGEX_SEPARATOR.length();
72         final int locationRegexPos = statusRegexPos + splits[1].length() + REGEX_SEPARATOR.length();
73         final Pattern statusRegex = parsePatternIfNonEmpty(splits[1], statusRegexPos);
74         final Pattern locationRegex = parsePatternIfNonEmpty(splits[2], locationRegexPos);
75 
76         return new RegexMatchProbeSpec(spec, new URL(splits[0]), statusRegex, locationRegex);
77     }
78 
79     @Nullable
parsePatternIfNonEmpty(String pattern, int pos)80     private static Pattern parsePatternIfNonEmpty(String pattern, int pos) throws ParseException {
81         if (TextUtils.isEmpty(pattern)) {
82             return null;
83         }
84         try {
85             return Pattern.compile(pattern);
86         } catch (PatternSyntaxException e) {
87             throw new ParseException(
88                     String.format("Invalid status pattern [%s]: %s", pattern, e),
89                     pos /* errorOffset */);
90         }
91     }
92 
93     /**
94      * Parse a {@link CaptivePortalProbeSpec} from a {@link String}, or return a fallback spec
95      * based on the status code of the provided URL if the spec cannot be parsed.
96      */
97     @Nullable
parseSpecOrNull(@ullable String spec)98     public static CaptivePortalProbeSpec parseSpecOrNull(@Nullable String spec) {
99         if (spec != null) {
100             try {
101                 return parseSpec(spec);
102             } catch (ParseException | MalformedURLException e) {
103                 Log.e(TAG, "Invalid probe spec: " + spec, e);
104                 // Fall through
105             }
106         }
107         return null;
108     }
109 
110     /**
111      * Parse a config String to build an array of {@link CaptivePortalProbeSpec}.
112      *
113      * <p>Each spec is separated by @@,@@ and follows the format for {@link #parseSpec(String)}.
114      * <p>This method does not throw but ignores any entry that could not be parsed.
115      */
parseCaptivePortalProbeSpecs(String settingsVal)116     public static CaptivePortalProbeSpec[] parseCaptivePortalProbeSpecs(String settingsVal) {
117         List<CaptivePortalProbeSpec> specs = new ArrayList<>();
118         if (settingsVal != null) {
119             for (String spec : TextUtils.split(settingsVal, SPEC_SEPARATOR)) {
120                 try {
121                     specs.add(parseSpec(spec));
122                 } catch (ParseException | MalformedURLException e) {
123                     Log.e(TAG, "Invalid probe spec: " + spec, e);
124                 }
125             }
126         }
127 
128         if (specs.isEmpty()) {
129             Log.e(TAG, String.format("could not create any validation spec from %s", settingsVal));
130         }
131         return specs.toArray(new CaptivePortalProbeSpec[specs.size()]);
132     }
133 
134     /**
135      * Get the probe result from HTTP status and location header.
136      */
getResult(int status, @Nullable String locationHeader)137     public abstract CaptivePortalProbeResult getResult(int status, @Nullable String locationHeader);
138 
getEncodedSpec()139     public String getEncodedSpec() {
140         return mEncodedSpec;
141     }
142 
getUrl()143     public URL getUrl() {
144         return mUrl;
145     }
146 
147     /**
148      * Implementation of {@link CaptivePortalProbeSpec} that is based on configurable regular
149      * expressions for the HTTP status code and location header (if any). Matches indicate that
150      * the page is not a portal.
151      * This probe cannot fail: it always returns SUCCESS_CODE or PORTAL_CODE
152      */
153     private static class RegexMatchProbeSpec extends CaptivePortalProbeSpec {
154         @Nullable
155         final Pattern mStatusRegex;
156         @Nullable
157         final Pattern mLocationHeaderRegex;
158 
RegexMatchProbeSpec( String spec, URL url, Pattern statusRegex, Pattern locationHeaderRegex)159         RegexMatchProbeSpec(
160                 String spec, URL url, Pattern statusRegex, Pattern locationHeaderRegex) {
161             super(spec, url);
162             mStatusRegex = statusRegex;
163             mLocationHeaderRegex = locationHeaderRegex;
164         }
165 
166         @Override
getResult(int status, String locationHeader)167         public CaptivePortalProbeResult getResult(int status, String locationHeader) {
168             final boolean statusMatch = safeMatch(String.valueOf(status), mStatusRegex);
169             final boolean locationMatch = safeMatch(locationHeader, mLocationHeaderRegex);
170             final int returnCode = statusMatch && locationMatch ? SUCCESS_CODE : PORTAL_CODE;
171             return new CaptivePortalProbeResult(
172                     returnCode, locationHeader, getUrl().toString(), this);
173         }
174     }
175 
safeMatch(@ullable String value, @Nullable Pattern pattern)176     private static boolean safeMatch(@Nullable String value, @Nullable Pattern pattern) {
177         // No value is a match ("no location header" passes the location rule for non-redirects)
178         return pattern == null || TextUtils.isEmpty(value) || pattern.matcher(value).matches();
179     }
180 }
181