1 /*
2  * Copyright (C) 2017 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 libcore.java.net;
18 
19 import junit.framework.TestCase;
20 
21 import org.mockftpserver.core.util.IoUtil;
22 import org.mockftpserver.fake.FakeFtpServer;
23 import org.mockftpserver.fake.UserAccount;
24 import org.mockftpserver.fake.filesystem.DirectoryEntry;
25 import org.mockftpserver.fake.filesystem.FileEntry;
26 import org.mockftpserver.fake.filesystem.UnixFakeFileSystem;
27 
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.io.OutputStream;
31 import java.net.MalformedURLException;
32 import java.net.Proxy;
33 import java.net.ProxySelector;
34 import java.net.ServerSocket;
35 import java.net.Socket;
36 import java.net.SocketAddress;
37 import java.net.SocketException;
38 import java.net.URI;
39 import java.net.URL;
40 import java.net.URLConnection;
41 import java.util.Arrays;
42 import java.util.Collections;
43 import java.util.List;
44 import java.util.Locale;
45 import java.util.Objects;
46 import java.util.Random;
47 import java.util.concurrent.CountDownLatch;
48 import java.util.concurrent.Semaphore;
49 import java.util.concurrent.TimeUnit;
50 
51 import sun.net.ftp.FtpLoginException;
52 
53 import static java.nio.charset.StandardCharsets.UTF_8;
54 
55 /**
56  * Tests URLConnections for ftp:// URLs.
57  */
58 public class FtpURLConnectionTest extends TestCase {
59 
60     private static final String FILE_PATH = "test/file/for/FtpURLConnectionTest.txt";
61     private static final String SERVER_HOSTNAME = "localhost";
62     private static final String VALID_USER = "user";
63     private static final String VALID_PASSWORD = "password";
64     private static final String VALID_USER_HOME_DIR = "/home/user";
65 
66     private FakeFtpServer fakeFtpServer;
67     private UnixFakeFileSystem fileSystem;
68 
69     @Override
setUp()70     public void setUp() throws Exception {
71         super.setUp();
72         fakeFtpServer = new FakeFtpServer();
73         fakeFtpServer.setServerControlPort(0 /* allocate port number automatically */);
74         fakeFtpServer.addUserAccount(new UserAccount(VALID_USER, VALID_PASSWORD,
75                 VALID_USER_HOME_DIR));
76         fileSystem = new UnixFakeFileSystem();
77         fakeFtpServer.setFileSystem(fileSystem);
78         fileSystem.add(new DirectoryEntry(VALID_USER_HOME_DIR));
79         fakeFtpServer.start();
80     }
81 
82     @Override
tearDown()83     public void tearDown() throws Exception {
84         fakeFtpServer.stop();
85         super.tearDown();
86     }
87 
testInputUrl()88     public void testInputUrl() throws Exception {
89         byte[] fileContents = "abcdef 1234567890".getBytes(UTF_8);
90         addFileEntry(FILE_PATH, fileContents);
91         URL fileUrl = getFileUrlWithCredentials(VALID_USER, VALID_PASSWORD, FILE_PATH);
92         URLConnection connection = fileUrl.openConnection();
93         assertContents(fileContents, connection.getInputStream());
94     }
95 
testInputUrl_invalidUserOrPassword()96     public void testInputUrl_invalidUserOrPassword() throws Exception {
97         checkInputUrl_invalidUserOrPassword("wrong_user", VALID_PASSWORD);
98         checkInputUrl_invalidUserOrPassword(VALID_USER, "wrong password");
99     }
100 
testInputUrl_missingPassword()101     public void testInputUrl_missingPassword() throws Exception {
102         URL noPasswordUrl = getFileUrlWithCredentials(VALID_USER, null, FILE_PATH);
103         URLConnection noPasswordConnection = noPasswordUrl.openConnection();
104         try {
105             noPasswordConnection.getInputStream();
106             fail();
107         } catch (IOException expected) {
108         }
109     }
110 
checkInputUrl_invalidUserOrPassword(String user, String password)111     private void checkInputUrl_invalidUserOrPassword(String user, String password)
112             throws IOException {
113         URL fileUrl = getFileUrlWithCredentials(user, password, FILE_PATH);
114         URLConnection connection = fileUrl.openConnection();
115         try {
116             connection.getInputStream();
117             fail();
118         } catch (sun.net.ftp.FtpLoginException expected) {
119             assertEquals("Invalid username/password", expected.getMessage());
120         }
121     }
122 
testOutputUrl()123     public void testOutputUrl() throws Exception {
124         byte[] fileContents = "abcdef 1234567890".getBytes(UTF_8);
125         addFileEntry("test/output-url/existing file.txt", fileContents);
126         byte[] newFileContents = "contents of brand new file".getBytes(UTF_8);
127         String filePath = "test/output-url/file that is newly created.txt";
128         URL fileUrl = getFileUrlWithCredentials(VALID_USER, VALID_PASSWORD, filePath);
129         URLConnection connection = fileUrl.openConnection();
130         connection.setDoInput(false);
131         connection.setDoOutput(true);
132         OutputStream os = connection.getOutputStream();
133         writeBytes(os, newFileContents);
134 
135         assertContents(newFileContents, openFileSystemContents(filePath));
136     }
137 
testConnectOverProxy_noProxy()138     public void testConnectOverProxy_noProxy() throws Exception {
139         Proxy proxy = Proxy.NO_PROXY;
140         byte[] fileContents = "abcdef 1234567890".getBytes(UTF_8);
141         URL fileUrl = addFileEntry(FILE_PATH, fileContents);
142         URLConnection connection = fileUrl.openConnection(proxy);
143         assertContents(fileContents, connection.getInputStream());
144         // Check that NO_PROXY covers the Type.DIRECT case
145         assertEquals(Proxy.Type.DIRECT, proxy.type());
146     }
147 
148     /**
149      * Tests that the helper class {@link CountingProxy} correctly accepts and
150      * counts connection attempts to the address represented by {@code asProxy()}.
151      */
testCountingProxy()152     public void testCountingProxy() throws Exception {
153         Socket socket = new Socket();
154         try {
155             CountingProxy countingProxy = CountingProxy.start();
156             try {
157                 Proxy proxy = countingProxy.asProxy();
158                 assertEquals(Proxy.Type.HTTP, proxy.type());
159                 SocketAddress address = proxy.address();
160                 socket.connect(address, /* timeout (msec) */ 200); // attempt one connection
161                 countingProxy.waitAndAssertConnectionCount(1);
162             } finally {
163                 countingProxy.shutdown();
164             }
165         } finally {
166             socket.close();
167         }
168     }
169 
170     /**
171      * Tests that a HTTP proxy explicitly passed to {@link URL#openConnection(Proxy)}
172      * ignores HTTP proxies (since it doesn't support them) and attempts a direct
173      * connection instead.
174      */
testConnectOverProxy_explicit_http_uses_direct_connection()175     public void testConnectOverProxy_explicit_http_uses_direct_connection() throws Exception {
176         byte[] fileContents = "abcdef 1234567890".getBytes(UTF_8);
177         URL fileUrl = addFileEntry(FILE_PATH, fileContents);
178         CountingProxy countingProxy = CountingProxy.start();
179         try {
180             Proxy proxy = countingProxy.asProxy();
181             URLConnection connection = fileUrl.openConnection(proxy);
182             // direct connection succeeds
183             assertContents(fileContents, connection.getInputStream());
184             countingProxy.waitAndAssertConnectionCount(0);
185         } finally {
186             countingProxy.shutdown();
187         }
188     }
189 
190     /**
191      * Tests that if a ProxySelector is set, any HTTP proxies selected for
192      * ftp:// URLs will be rejected. A direct connection will
193      * be selected once the ProxySelector's proxies have failed.
194      */
testConnectOverProxy_implicit_http_fails()195     public void testConnectOverProxy_implicit_http_fails() throws Exception {
196         byte[] fileContents = "abcdef 1234567890".getBytes(UTF_8);
197         URL fileUrl = addFileEntry(FILE_PATH, fileContents);
198         ProxySelector defaultProxySelector = ProxySelector.getDefault();
199         try {
200             CountingProxy countingProxy = CountingProxy.start();
201             try {
202                 Proxy proxy = countingProxy.asProxy();
203                 SingleProxySelector proxySelector = new SingleProxySelector(proxy);
204                 ProxySelector.setDefault(proxySelector);
205                 URLConnection connection = fileUrl.openConnection();
206                 InputStream inputStream = connection.getInputStream();
207 
208                 IOException e = proxySelector.getLastException();
209                 assertEquals("FTP connections over HTTP proxy not supported",
210                         e.getMessage());
211 
212                 // The direct connection is successful
213                 assertContents(fileContents, inputStream);
214                 countingProxy.waitAndAssertConnectionCount(0);
215             } finally {
216                 countingProxy.shutdown();
217             }
218         } finally {
219             ProxySelector.setDefault(defaultProxySelector);
220         }
221     }
222 
testInputUrlWithSpaces()223     public void testInputUrlWithSpaces() throws Exception {
224         byte[] fileContents = "abcdef 1234567890".getBytes(UTF_8);
225         URL url = addFileEntry("file with spaces.txt", fileContents);
226         URLConnection connection = url.openConnection();
227         assertContents(fileContents, connection.getInputStream());
228     }
229 
testBinaryFileContents()230     public void testBinaryFileContents() throws Exception {
231         byte[] data = new byte[4096];
232         new Random(31337).nextBytes(data); // arbitrary pseudo-random but repeatable test data
233         URL url = addFileEntry("binaryfile.dat", data.clone());
234         assertContents(data, url.openConnection().getInputStream());
235     }
236 
237     // https://code.google.com/p/android/issues/detail?id=160725
testInputUrlWithSpacesViaProxySelector()238     public void testInputUrlWithSpacesViaProxySelector() throws Exception {
239         byte[] fileContents = "abcdef 1234567890".getBytes(UTF_8);
240         ProxySelector defaultProxySelector = ProxySelector.getDefault();
241         try {
242             SingleProxySelector proxySelector = new SingleProxySelector(Proxy.NO_PROXY);
243             ProxySelector.setDefault(proxySelector);
244             URL url = addFileEntry("file with spaces.txt", fileContents);
245             assertContents(fileContents, url.openConnection().getInputStream());
246             assertNull(proxySelector.getLastException());
247         } finally {
248             ProxySelector.setDefault(defaultProxySelector);
249         }
250     }
251 
252     // http://b/35784677
testCRLFInUserinfo()253     public void testCRLFInUserinfo() throws Exception {
254         int serverPort = fakeFtpServer.getServerControlPort();
255         List<String> encodedUserInfos = Arrays.asList(
256                 // '\r\n' in the username with password
257                 "user%0D%0Acommand:password",
258                 // '\r\n' in the password
259                 "user:password%0D%0Acommand",
260                 // just '\n' in the password
261                 "user:password%0Acommand",
262                 // just '\n' in the username
263                 "user%0Acommand:password"
264         );
265         for (String encodedUserInfo : encodedUserInfos) {
266             String urlString = String.format(Locale.US, "ftp://%s@%s:%s/%s",
267                     encodedUserInfo, SERVER_HOSTNAME, serverPort, FILE_PATH);
268             try {
269                 new URL(urlString).openConnection().connect();
270                 fail("Connection shouldn't have succeeded: " + urlString);
271             } catch (FtpLoginException expected) {
272                 // The original message "Illegal carriage return" gets lost
273                 // where FtpURLConnection.connect() translates the
274                 // original FtpProtocolException into FtpLoginException.
275                 assertEquals("Invalid username/password", expected.getMessage());
276             }
277         }
278     }
279 
openFileSystemContents(String fileName)280     private InputStream openFileSystemContents(String fileName) throws IOException {
281         String fullFileName = VALID_USER_HOME_DIR + "/" + fileName;
282         FileEntry entry = (FileEntry) fileSystem.getEntry(fullFileName);
283         assertNotNull("File must exist with name " + fullFileName, entry);
284         return entry.createInputStream();
285     }
286 
writeBytes(OutputStream os, byte[] fileContents)287     private static void writeBytes(OutputStream os, byte[] fileContents) throws IOException {
288         os.write(fileContents);
289         os.close();
290     }
291 
assertContents(byte[] expectedContents, InputStream inputStream)292     private static void assertContents(byte[] expectedContents, InputStream inputStream)
293             throws IOException {
294         try {
295             byte[] contentBytes = IoUtil.readBytes(inputStream);
296             if (!Arrays.equals(expectedContents, contentBytes)) {
297                 // optimize the error message for the case of the content being character data
298                 fail("Expected " + new String(expectedContents, UTF_8) + ", but got "
299                         + new String(contentBytes, UTF_8));
300             }
301         } finally {
302             inputStream.close();
303         }
304     }
305 
getFileUrlWithCredentials(String user, String password, String filePath)306     private URL getFileUrlWithCredentials(String user, String password, String filePath) {
307         Objects.requireNonNull(user);
308         Objects.requireNonNull(filePath);
309         int serverPort = fakeFtpServer.getServerControlPort();
310         String credentials = user + (password == null ? "" : (":" + password));
311         String urlString = String.format(Locale.US, "ftp://%s@%s:%s/%s",
312                 credentials, SERVER_HOSTNAME, serverPort, filePath);
313         try {
314             return new URL(urlString);
315         } catch (MalformedURLException e) {
316             fail("Malformed URL: " + urlString);
317             throw new AssertionError("Can never happen");
318         }
319     }
320 
addFileEntry(String filePath, byte[] fileContents)321     private URL addFileEntry(String filePath, byte[] fileContents) {
322         FileEntry fileEntry = new FileEntry(VALID_USER_HOME_DIR + "/" + filePath);
323         fileEntry.setContents(fileContents);
324         fileSystem.add(fileEntry);
325         return getFileUrlWithCredentials(VALID_USER, VALID_PASSWORD, filePath);
326     }
327 
328     /**
329      * A {@link ProxySelector} that selects the same (given) Proxy for all URIs.
330      */
331     static class SingleProxySelector extends ProxySelector {
332         private final Proxy proxy;
333         private IOException lastException = null;
334 
SingleProxySelector(Proxy proxy)335         public SingleProxySelector(Proxy proxy) {
336             this.proxy = proxy;
337         }
338 
339         @Override
select(URI uri)340         public List<Proxy> select(URI uri) {
341             assertNotNull(uri);
342             return Collections.singletonList(proxy);
343         }
344 
345         @Override
connectFailed(URI uri, SocketAddress sa, IOException ioe)346         public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
347             lastException = ioe;
348         }
349 
getLastException()350         public IOException getLastException() {
351             return lastException;
352         }
353     }
354 
355     /**
356      * Counts the number of attempts to connect to a ServerSocket exposed
357      * {@link #asProxy() as a Proxy}. From {@link #start()} until
358      * {@link #shutdown()}, a background server thread accepts and counts
359      * connections on the socket but immediately closes them without
360      * reading any data.
361      */
362     static class CountingProxy {
363         class ServerThread extends Thread {
ServerThread(String name)364             public ServerThread(String name) {
365                 super(name);
366             }
367 
368             @Override
run()369             public void run() {
370                 while (true) {
371                     try {
372                         Socket socket = serverSocket.accept();
373                         connectionAttempts.release(1); // count one connection attempt
374                         socket.close();
375                     } catch (SocketException e) {
376                         shutdownLatch.countDown();
377                         return;
378                     } catch (IOException e) {
379                         // retry
380                     }
381                 }
382             }
383         }
384 
385         // Signals that serverThread has gracefully completed shutdown (not crashed)
386         private final CountDownLatch shutdownLatch = new CountDownLatch(1);
387         private final ServerSocket serverSocket;
388         private final Proxy proxy;
389         private final Thread serverThread;
390         // holds one permit for each connection attempt encountered; this allows
391         // us to block until a certain number of attempts have taken place.
392         private final Semaphore connectionAttempts = new Semaphore(0);
393 
CountingProxy()394         private CountingProxy() throws IOException {
395             serverSocket = new ServerSocket(0 /* allocate port number automatically */);
396             SocketAddress socketAddress = serverSocket.getLocalSocketAddress();
397             proxy = new Proxy(Proxy.Type.HTTP, socketAddress);
398             String threadName = getClass().getSimpleName() + " @ " + socketAddress;
399             serverThread = new ServerThread(threadName);
400         }
401 
start()402         public static CountingProxy start() throws IOException {
403             CountingProxy result = new CountingProxy();
404             // only start the thread once the object has been properly constructed
405             result.serverThread.start();
406             try {
407                 // Give ServerThread time to call accept().
408                 Thread.sleep(300);
409             } catch (InterruptedException e) {
410                 throw new IOException("Unexpectedly interrupted", e);
411             }
412             return result;
413         }
414 
415         /**
416          * Returns the HTTP {@link Proxy} that can represents the ServerSocket
417          * connections to which this class manages/counts.
418          */
asProxy()419         public Proxy asProxy() {
420             return proxy;
421         }
422 
423         /**
424          * Causes the ServerSocket represented by {@link #asProxy()} to stop accepting
425          * connections by shutting down the server thread.
426          *
427          * @return the number of connections that were attempted during the proxy's lifetime
428          */
waitAndAssertConnectionCount(int expectedConnectionAttempts)429         public void waitAndAssertConnectionCount(int expectedConnectionAttempts)
430                 throws IOException, InterruptedException {
431             // Wait for a timeout, or fail early if expected # of connections is exceeded
432             boolean tooManyConnections = connectionAttempts.tryAcquire(
433                     expectedConnectionAttempts + 1, 300, TimeUnit.MILLISECONDS);
434             assertFalse("Observed more connections than the expected " + expectedConnectionAttempts,
435                     tooManyConnections);
436             assertEquals(expectedConnectionAttempts, connectionAttempts.availablePermits());
437         }
438 
shutdown()439         public void shutdown() throws IOException, InterruptedException {
440             serverSocket.close();
441             // Check that the server shuts down quickly and gracefully via the expected
442             // code path (as opposed to an uncaught exception).
443             shutdownLatch.await(1, TimeUnit.SECONDS);
444             serverThread.join(1000);
445             assertFalse("serverThread failed to shut down quickly", serverThread.isAlive());
446         }
447 
448         @Override
toString()449         public String toString() {
450             return serverThread.toString() ;
451         }
452     }
453 
454 }
455