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 import static android.net.metrics.ValidationProbeEvent.PROBE_HTTP;
22 
23 import android.text.TextUtils;
24 import android.util.Log;
25 
26 import androidx.annotation.NonNull;
27 import androidx.annotation.Nullable;
28 import androidx.annotation.VisibleForTesting;
29 
30 import java.net.MalformedURLException;
31 import java.net.URL;
32 import java.text.ParseException;
33 import java.util.ArrayList;
34 import java.util.Collection;
35 import java.util.List;
36 import java.util.regex.Pattern;
37 import java.util.regex.PatternSyntaxException;
38 
39 /** @hide */
40 public abstract class CaptivePortalProbeSpec {
41     private static final String TAG = CaptivePortalProbeSpec.class.getSimpleName();
42     private static final String REGEX_SEPARATOR = "@@/@@";
43     private static final String SPEC_SEPARATOR = "@@,@@";
44     private static final String PROTOCOL_HTTP = "http";
45 
46     private final String mEncodedSpec;
47     private final URL mUrl;
48 
CaptivePortalProbeSpec(@onNull String encodedSpec, @NonNull URL url)49     CaptivePortalProbeSpec(@NonNull String encodedSpec, @NonNull URL url) {
50         mEncodedSpec = checkNotNull(encodedSpec);
51         mUrl = checkNotNull(url);
52     }
53 
54     /**
55      * Parse a {@link CaptivePortalProbeSpec} from a {@link String}.
56      *
57      * <p>The valid format is a URL followed by two regular expressions, each separated by "@@/@@".
58      * @throws MalformedURLException The URL has invalid format for {@link URL#URL(String)}.
59      * @throws ParseException The string is empty, does not match the above format, or a regular
60      * expression is invalid for {@link Pattern#compile(String)}.
61      * @hide
62      */
63     @VisibleForTesting
64     @NonNull
parseSpec(@onNull String spec)65     public static CaptivePortalProbeSpec parseSpec(@NonNull String spec) throws ParseException,
66             MalformedURLException {
67         if (TextUtils.isEmpty(spec)) {
68             throw new ParseException("Empty probe spec", 0 /* errorOffset */);
69         }
70 
71         String[] splits = TextUtils.split(spec, REGEX_SEPARATOR);
72         if (splits.length != 3) {
73             throw new ParseException("Probe spec does not have 3 parts", 0 /* errorOffset */);
74         }
75 
76         final int statusRegexPos = splits[0].length() + REGEX_SEPARATOR.length();
77         final int locationRegexPos = statusRegexPos + splits[1].length() + REGEX_SEPARATOR.length();
78         final Pattern statusRegex = parsePatternIfNonEmpty(splits[1], statusRegexPos);
79         final Pattern locationRegex = parsePatternIfNonEmpty(splits[2], locationRegexPos);
80 
81         return new RegexMatchProbeSpec(spec, new URL(splits[0]), statusRegex, locationRegex);
82     }
83 
84     @Nullable
parsePatternIfNonEmpty(@ullable String pattern, int pos)85     private static Pattern parsePatternIfNonEmpty(@Nullable String pattern, int pos)
86             throws ParseException {
87         if (TextUtils.isEmpty(pattern)) {
88             return null;
89         }
90         try {
91             return Pattern.compile(pattern);
92         } catch (PatternSyntaxException e) {
93             throw new ParseException(
94                     String.format("Invalid status pattern [%s]: %s", pattern, e),
95                     pos /* errorOffset */);
96         }
97     }
98 
99     /**
100      * Parse a {@link CaptivePortalProbeSpec} from a {@link String}, or return a fallback spec
101      * based on the status code of the provided URL if the spec cannot be parsed.
102      */
103     @Nullable
parseSpecOrNull(@ullable String spec)104     public static CaptivePortalProbeSpec parseSpecOrNull(@Nullable String spec) {
105         if (spec != null) {
106             try {
107                 return parseSpec(spec);
108             } catch (ParseException | MalformedURLException e) {
109                 Log.e(TAG, "Invalid probe spec: " + spec, e);
110                 // Fall through
111             }
112         }
113         return null;
114     }
115 
116     /**
117      * Parse a config String to build an array of {@link CaptivePortalProbeSpec}.
118      *
119      * <p>Each spec is separated by @@,@@ and follows the format for {@link #parseSpec(String)}.
120      * <p>This method does not throw but ignores any entry that could not be parsed.
121      */
122     @NonNull
parseCaptivePortalProbeSpecs( @onNull String settingsVal)123     public static Collection<CaptivePortalProbeSpec> parseCaptivePortalProbeSpecs(
124             @NonNull String settingsVal) {
125         List<CaptivePortalProbeSpec> specs = new ArrayList<>();
126         if (settingsVal != null) {
127             for (String spec : TextUtils.split(settingsVal, SPEC_SEPARATOR)) {
128                 try {
129                     specs.add(parseSpec(spec));
130                 } catch (ParseException | MalformedURLException e) {
131                     Log.e(TAG, "Invalid probe spec: " + spec, e);
132                 }
133             }
134         }
135 
136         if (specs.isEmpty()) {
137             Log.e(TAG, String.format("could not create any validation spec from %s", settingsVal));
138         }
139         return specs;
140     }
141 
142     /**
143      * Get the HTTP probe result from HTTP status and location header.
144      */
145     @NonNull
getResult(int status, @Nullable String locationHeader)146     public abstract CaptivePortalProbeResult getResult(int status, @Nullable String locationHeader);
147 
148     @NonNull
getEncodedSpec()149     public String getEncodedSpec() {
150         return mEncodedSpec;
151     }
152 
153     @NonNull
getUrl()154     public URL getUrl() {
155         return mUrl;
156     }
157 
158     /**
159      * Implementation of {@link CaptivePortalProbeSpec} that is based on configurable regular
160      * expressions for the HTTP status code and location header (if any). Matches indicate that
161      * the page is not a portal.
162      * @throws IllegalArgumentException The protocol of the url is not http.
163      * This probe cannot fail: it always returns SUCCESS_CODE or PORTAL_CODE
164      */
165     private static class RegexMatchProbeSpec extends CaptivePortalProbeSpec {
166         @Nullable
167         final Pattern mStatusRegex;
168         @Nullable
169         final Pattern mLocationHeaderRegex;
170 
RegexMatchProbeSpec(@onNull String spec, @NonNull URL url, @Nullable Pattern statusRegex, @Nullable Pattern locationHeaderRegex)171         RegexMatchProbeSpec(@NonNull String spec, @NonNull URL url, @Nullable Pattern statusRegex,
172                 @Nullable Pattern locationHeaderRegex) throws ParseException {
173             super(spec, url);
174             final String protocol = url.getProtocol();
175             if (!PROTOCOL_HTTP.equals(protocol)) {
176                 // The probe type taken in the result is hard-coded as PROBE_HTTP currently, so the
177                 // probe type taken into the result of {@code getResult} have to change if other
178                 // kind of probes are used.
179                 throw new IllegalArgumentException("Protocol for probe spec should be http but was"
180                         + protocol);
181             }
182             mStatusRegex = statusRegex;
183             mLocationHeaderRegex = locationHeaderRegex;
184         }
185 
186         @Override
getResult(int status, String locationHeader)187         public CaptivePortalProbeResult getResult(int status, String locationHeader) {
188             final boolean statusMatch = safeMatch(String.valueOf(status), mStatusRegex);
189             final boolean locationMatch = safeMatch(locationHeader, mLocationHeaderRegex);
190             final int returnCode = statusMatch && locationMatch ? SUCCESS_CODE : PORTAL_CODE;
191             return new CaptivePortalProbeResult(
192                     returnCode, locationHeader, getUrl().toString(), this, PROBE_HTTP);
193         }
194     }
195 
safeMatch(@ullable String value, @Nullable Pattern pattern)196     private static boolean safeMatch(@Nullable String value, @Nullable Pattern pattern) {
197         // No value is a match ("no location header" passes the location rule for non-redirects)
198         return pattern == null || TextUtils.isEmpty(value) || pattern.matcher(value).matches();
199     }
200 
201     // Throws NullPointerException if the input is null.
checkNotNull(T object)202     private static <T> T checkNotNull(T object) {
203         if (object == null) throw new NullPointerException();
204         return object;
205     }
206 }
207