View Javadoc
1   /*
2    * Copyright (C) 2018, Konrad Windszus <konrad_w@gmx.de>
3    * and other copyright owners as documented in the project's IP log.
4    *
5    * This program and the accompanying materials are made available
6    * under the terms of the Eclipse Distribution License v1.0 which
7    * accompanies this distribution, is reproduced below, and is
8    * available at http://www.eclipse.org/org/documents/edl-v10.php
9    *
10   * All rights reserved.
11   *
12   * Redistribution and use in source and binary forms, with or
13   * without modification, are permitted provided that the following
14   * conditions are met:
15   *
16   * - Redistributions of source code must retain the above copyright
17   *   notice, this list of conditions and the following disclaimer.
18   *
19   * - Redistributions in binary form must reproduce the above
20   *   copyright notice, this list of conditions and the following
21   *   disclaimer in the documentation and/or other materials provided
22   *   with the distribution.
23   *
24   * - Neither the name of the Eclipse Foundation, Inc. nor the
25   *   names of its contributors may be used to endorse or promote
26   *   products derived from this software without specific prior
27   *   written permission.
28   *
29   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
30   * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
31   * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
32   * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
33   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
34   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
35   * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
36   * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
37   * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
38   * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
39   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
40   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
41   * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
42   */
43  package org.eclipse.jgit.internal.transport.http;
44  
45  import java.io.BufferedReader;
46  import java.io.ByteArrayOutputStream;
47  import java.io.File;
48  import java.io.FileNotFoundException;
49  import java.io.IOException;
50  import java.io.OutputStreamWriter;
51  import java.io.StringReader;
52  import java.io.Writer;
53  import java.net.HttpCookie;
54  import java.net.URL;
55  import java.nio.charset.StandardCharsets;
56  import java.nio.file.Path;
57  import java.text.MessageFormat;
58  import java.util.Arrays;
59  import java.util.Collection;
60  import java.util.Date;
61  import java.util.LinkedHashSet;
62  import java.util.Set;
63  
64  import org.eclipse.jgit.annotations.NonNull;
65  import org.eclipse.jgit.annotations.Nullable;
66  import org.eclipse.jgit.internal.JGitText;
67  import org.eclipse.jgit.internal.storage.file.FileSnapshot;
68  import org.eclipse.jgit.internal.storage.file.LockFile;
69  import org.eclipse.jgit.lib.Constants;
70  import org.eclipse.jgit.storage.file.FileBasedConfig;
71  import org.eclipse.jgit.util.FileUtils;
72  import org.eclipse.jgit.util.IO;
73  import org.eclipse.jgit.util.RawParseUtils;
74  import org.slf4j.Logger;
75  import org.slf4j.LoggerFactory;
76  
77  /**
78   * Wraps all cookies persisted in a <strong>Netscape Cookie File Format</strong>
79   * being referenced via the git config <a href=
80   * "https://git-scm.com/docs/git-config#git-config-httpcookieFile">http.cookieFile</a>.
81   * <p>
82   * It will only load the cookies lazily, i.e. before calling
83   * {@link #getCookies(boolean)} the file is not evaluated. This class also
84   * allows persisting cookies in that file format.
85   * <p>
86   * In general this class is not thread-safe. So any consumer needs to take care
87   * of synchronization!
88   *
89   * @see <a href="http://www.cookiecentral.com/faq/#3.5">Netscape Cookie File
90   *      Format</a>
91   * @see <a href=
92   *      "https://unix.stackexchange.com/questions/36531/format-of-cookies-when-using-wget">Cookie
93   *      format for wget</a>
94   * @see <a href=
95   *      "https://github.com/curl/curl/blob/07ebaf837843124ee670e5b8c218b80b92e06e47/lib/cookie.c#L745">libcurl
96   *      Cookie file parsing</a>
97   * @see <a href=
98   *      "https://github.com/curl/curl/blob/07ebaf837843124ee670e5b8c218b80b92e06e47/lib/cookie.c#L1417">libcurl
99   *      Cookie file writing</a>
100  * @see NetscapeCookieFileCache
101  */
102 public final class NetscapeCookieFile {
103 
104 	private static final String HTTP_ONLY_PREAMBLE = "#HttpOnly_"; //$NON-NLS-1$
105 
106 	private static final String COLUMN_SEPARATOR = "\t"; //$NON-NLS-1$
107 
108 	private static final String LINE_SEPARATOR = "\n"; //$NON-NLS-1$
109 
110 	/**
111 	 * Maximum number of retries to acquire the lock for writing to the
112 	 * underlying file.
113 	 */
114 	private static final int LOCK_ACQUIRE_MAX_RETRY_COUNT = 4;
115 
116 	/**
117 	 * Sleep time in milliseconds between retries to acquire the lock for
118 	 * writing to the underlying file.
119 	 */
120 	private static final int LOCK_ACQUIRE_RETRY_SLEEP = 500;
121 
122 	private final Path path;
123 
124 	private FileSnapshot snapshot;
125 
126 	private byte[] hash;
127 
128 	final Date creationDate;
129 
130 	private Set<HttpCookie> cookies = null;
131 
132 	private static final Logger LOG = LoggerFactory
133 			.getLogger(NetscapeCookieFile.class);
134 
135 	/**
136 	 * @param path
137 	 *            where to find the cookie file
138 	 */
139 	public NetscapeCookieFile(Path path) {
140 		this(path, new Date());
141 	}
142 
143 	NetscapeCookieFile(Path path, Date creationDate) {
144 		this.path = path;
145 		this.snapshot = FileSnapshot.DIRTY;
146 		this.creationDate = creationDate;
147 	}
148 
149 	/**
150 	 * Path to the underlying cookie file.
151 	 *
152 	 * @return the path
153 	 */
154 	public Path getPath() {
155 		return path;
156 	}
157 
158 	/**
159 	 * Return all cookies from the underlying cookie file.
160 	 *
161 	 * @param refresh
162 	 *            if {@code true} updates the list from the underlying cookie
163 	 *            file if it has been modified since the last read otherwise
164 	 *            returns the current transient state. In case the cookie file
165 	 *            has never been read before will always read from the
166 	 *            underlying file disregarding the value of this parameter.
167 	 * @return all cookies (may contain session cookies as well). This does not
168 	 *         return a copy of the list but rather the original one. Every
169 	 *         addition to the returned list can afterwards be persisted via
170 	 *         {@link #write(URL)}. Errors in the underlying file will not lead
171 	 *         to exceptions but rather to an empty set being returned and the
172 	 *         underlying error being logged.
173 	 */
174 	public Set<HttpCookie> getCookies(boolean refresh) {
175 		if (cookies == null || refresh) {
176 			try {
177 				byte[] in = getFileContentIfModified();
178 				Set<HttpCookie> newCookies = parseCookieFile(in, creationDate);
179 				if (cookies != null) {
180 					cookies = mergeCookies(newCookies, cookies);
181 				} else {
182 					cookies = newCookies;
183 				}
184 				return cookies;
185 			} catch (IOException | IllegalArgumentException e) {
186 				LOG.warn(
187 						MessageFormat.format(
188 								JGitText.get().couldNotReadCookieFile, path),
189 						e);
190 				if (cookies == null) {
191 					cookies = new LinkedHashSet<>();
192 				}
193 			}
194 		}
195 		return cookies;
196 
197 	}
198 
199 	/**
200 	 * Parses the given file and extracts all cookie information from it.
201 	 *
202 	 * @param input
203 	 *            the file content to parse
204 	 * @param creationDate
205 	 *            the date for the creation of the cookies (used to calculate
206 	 *            the maxAge based on the expiration date given within the file)
207 	 * @return the set of parsed cookies from the given file (even expired
208 	 *         ones). If there is more than one cookie with the same name in
209 	 *         this file the last one overwrites the first one!
210 	 * @throws IOException
211 	 *             if the given file could not be read for some reason
212 	 * @throws IllegalArgumentException
213 	 *             if the given file does not have a proper format
214 	 */
215 	private static Set<HttpCookie> parseCookieFile(@NonNull byte[] input,
216 			@NonNull Date creationDate)
217 			throws IOException, IllegalArgumentException {
218 
219 		String decoded = RawParseUtils.decode(StandardCharsets.US_ASCII, input);
220 
221 		Set<HttpCookie> cookies = new LinkedHashSet<>();
222 		try (BufferedReader reader = new BufferedReader(
223 				new StringReader(decoded))) {
224 			String line;
225 			while ((line = reader.readLine()) != null) {
226 				HttpCookie cookie = parseLine(line, creationDate);
227 				if (cookie != null) {
228 					cookies.add(cookie);
229 				}
230 			}
231 		}
232 		return cookies;
233 	}
234 
235 	private static HttpCookie parseLine(@NonNull String line,
236 			@NonNull Date creationDate) {
237 		if (line.isEmpty() || (line.startsWith("#") //$NON-NLS-1$
238 				&& !line.startsWith(HTTP_ONLY_PREAMBLE))) {
239 			return null;
240 		}
241 		String[] cookieLineParts = line.split(COLUMN_SEPARATOR, 7);
242 		if (cookieLineParts == null) {
243 			throw new IllegalArgumentException(MessageFormat
244 					.format(JGitText.get().couldNotFindTabInLine, line));
245 		}
246 		if (cookieLineParts.length < 7) {
247 			throw new IllegalArgumentException(MessageFormat.format(
248 					JGitText.get().couldNotFindSixTabsInLine,
249 					Integer.valueOf(cookieLineParts.length), line));
250 		}
251 		String name = cookieLineParts[5];
252 		String value = cookieLineParts[6];
253 		HttpCookie cookie = new HttpCookie(name, value);
254 
255 		String domain = cookieLineParts[0];
256 		if (domain.startsWith(HTTP_ONLY_PREAMBLE)) {
257 			cookie.setHttpOnly(true);
258 			domain = domain.substring(HTTP_ONLY_PREAMBLE.length());
259 		}
260 		// strip off leading "."
261 		// (https://tools.ietf.org/html/rfc6265#section-5.2.3)
262 		if (domain.startsWith(".")) { //$NON-NLS-1$
263 			domain = domain.substring(1);
264 		}
265 		cookie.setDomain(domain);
266 		// domain evaluation as boolean flag not considered (i.e. always assumed
267 		// to be true)
268 		cookie.setPath(cookieLineParts[2]);
269 		cookie.setSecure(Boolean.parseBoolean(cookieLineParts[3]));
270 
271 		long expires = Long.parseLong(cookieLineParts[4]);
272 		long maxAge = (expires - creationDate.getTime()) / 1000;
273 		if (maxAge <= 0) {
274 			return null; // skip expired cookies
275 		}
276 		cookie.setMaxAge(maxAge);
277 		return cookie;
278 	}
279 
280 	/**
281 	 * Read the underying file and return its content but only in case it has
282 	 * been modified since the last access.
283 	 * <p>
284 	 * Internally calculates the hash and maintains {@link FileSnapshot}s to
285 	 * prevent issues described as <a href=
286 	 * "https://github.com/git/git/blob/master/Documentation/technical/racy-git.txt">"Racy
287 	 * Git problem"</a>. Inspired by {@link FileBasedConfig#load()}.
288 	 *
289 	 * @return the file contents in case the file has been modified since the
290 	 *         last access, otherwise {@code null}
291 	 * @throws IOException
292 	 *             if the file is not found or cannot be read
293 	 */
294 	private byte[] getFileContentIfModified() throws IOException {
295 		final int maxStaleRetries = 5;
296 		int retries = 0;
297 		File file = getPath().toFile();
298 		if (!file.exists()) {
299 			LOG.warn(MessageFormat.format(JGitText.get().missingCookieFile,
300 					file.getAbsolutePath()));
301 			return new byte[0];
302 		}
303 		while (true) {
304 			final FileSnapshot oldSnapshot = snapshot;
305 			final FileSnapshot newSnapshot = FileSnapshot.save(file);
306 			try {
307 				final byte[] in = IO.readFully(file);
308 				byte[] newHash = hash(in);
309 				if (Arrays.equals(hash, newHash)) {
310 					if (oldSnapshot.equals(newSnapshot)) {
311 						oldSnapshot.setClean(newSnapshot);
312 					} else {
313 						snapshot = newSnapshot;
314 					}
315 				} else {
316 					snapshot = newSnapshot;
317 					hash = newHash;
318 				}
319 				return in;
320 			} catch (FileNotFoundException e) {
321 				throw e;
322 			} catch (IOException e) {
323 				if (FileUtils.isStaleFileHandle(e)
324 						&& retries < maxStaleRetries) {
325 					if (LOG.isDebugEnabled()) {
326 						LOG.debug(MessageFormat.format(
327 								JGitText.get().configHandleIsStale,
328 								Integer.valueOf(retries)), e);
329 					}
330 					retries++;
331 					continue;
332 				}
333 				throw new IOException(MessageFormat
334 						.format(JGitText.get().cannotReadFile, getPath()), e);
335 			}
336 		}
337 
338 	}
339 
340 	private static byte[] hash(final byte[] in) {
341 		return Constants.newMessageDigest().digest(in);
342 	}
343 
344 	/**
345 	 * Writes all the cookies being maintained in the set being returned by
346 	 * {@link #getCookies(boolean)} to the underlying file.
347 	 * <p>
348 	 * Session-cookies will not be persisted.
349 	 *
350 	 * @param url
351 	 *            url for which to write the cookies (important to derive
352 	 *            default values for non-explicitly set attributes)
353 	 * @throws IOException
354 	 *             if the underlying cookie file could not be read or written or
355 	 *             a problem with the lock file
356 	 * @throws InterruptedException
357 	 *             if the thread is interrupted while waiting for the lock
358 	 */
359 	public void write(URL url) throws IOException, InterruptedException {
360 		try {
361 			byte[] cookieFileContent = getFileContentIfModified();
362 			if (cookieFileContent != null) {
363 				LOG.debug("Reading the underlying cookie file '{}' " //$NON-NLS-1$
364 						+ "as it has been modified since " //$NON-NLS-1$
365 						+ "the last access", //$NON-NLS-1$
366 						path);
367 				// reread new changes if necessary
368 				Set<HttpCookie> cookiesFromFile = NetscapeCookieFile
369 						.parseCookieFile(cookieFileContent, creationDate);
370 				this.cookies = mergeCookies(cookiesFromFile, cookies);
371 			}
372 		} catch (FileNotFoundException e) {
373 			// ignore if file previously did not exist yet!
374 		}
375 
376 		ByteArrayOutputStream output = new ByteArrayOutputStream();
377 		try (Writer writer = new OutputStreamWriter(output,
378 				StandardCharsets.US_ASCII)) {
379 			write(writer, cookies, url, creationDate);
380 		}
381 		LockFile lockFile = new LockFile(path.toFile());
382 		for (int retryCount = 0; retryCount < LOCK_ACQUIRE_MAX_RETRY_COUNT; retryCount++) {
383 			if (lockFile.lock()) {
384 				try {
385 					lockFile.setNeedSnapshot(true);
386 					lockFile.write(output.toByteArray());
387 					if (!lockFile.commit()) {
388 						throw new IOException(MessageFormat.format(
389 								JGitText.get().cannotCommitWriteTo, path));
390 					}
391 				} finally {
392 					lockFile.unlock();
393 				}
394 				return;
395 			}
396 			Thread.sleep(LOCK_ACQUIRE_RETRY_SLEEP);
397 		}
398 		throw new IOException(
399 				MessageFormat.format(JGitText.get().cannotLock, lockFile));
400 	}
401 
402 	/**
403 	 * Writes the given cookies to the file in the Netscape Cookie File Format
404 	 * (also used by curl).
405 	 *
406 	 * @param writer
407 	 *            the writer to use to persist the cookies
408 	 * @param cookies
409 	 *            the cookies to write into the file
410 	 * @param url
411 	 *            the url for which to write the cookie (to derive the default
412 	 *            values for certain cookie attributes)
413 	 * @param creationDate
414 	 *            the date when the cookie has been created. Important for
415 	 *            calculation the cookie expiration time (calculated from
416 	 *            cookie's maxAge and this creation time)
417 	 * @throws IOException
418 	 *             if an I/O error occurs
419 	 */
420 	static void write(@NonNull Writer writer,
421 			@NonNull Collection<HttpCookie> cookies, @NonNull URL url,
422 			@NonNull Date creationDate) throws IOException {
423 		for (HttpCookie cookie : cookies) {
424 			writeCookie(writer, cookie, url, creationDate);
425 		}
426 	}
427 
428 	private static void writeCookie(@NonNull Writer writer,
429 			@NonNull HttpCookie cookie, @NonNull URL url,
430 			@NonNull Date creationDate) throws IOException {
431 		if (cookie.getMaxAge() <= 0) {
432 			return; // skip expired cookies
433 		}
434 		String domain = ""; //$NON-NLS-1$
435 		if (cookie.isHttpOnly()) {
436 			domain = HTTP_ONLY_PREAMBLE;
437 		}
438 		if (cookie.getDomain() != null) {
439 			domain += cookie.getDomain();
440 		} else {
441 			domain += url.getHost();
442 		}
443 		writer.write(domain);
444 		writer.write(COLUMN_SEPARATOR);
445 		writer.write("TRUE"); //$NON-NLS-1$
446 		writer.write(COLUMN_SEPARATOR);
447 		String path = cookie.getPath();
448 		if (path == null) {
449 			path = url.getPath();
450 		}
451 		writer.write(path);
452 		writer.write(COLUMN_SEPARATOR);
453 		writer.write(Boolean.toString(cookie.getSecure()).toUpperCase());
454 		writer.write(COLUMN_SEPARATOR);
455 		final String expirationDate;
456 		// whenCreated field is not accessible in HttpCookie
457 		expirationDate = String
458 				.valueOf(creationDate.getTime() + (cookie.getMaxAge() * 1000));
459 		writer.write(expirationDate);
460 		writer.write(COLUMN_SEPARATOR);
461 		writer.write(cookie.getName());
462 		writer.write(COLUMN_SEPARATOR);
463 		writer.write(cookie.getValue());
464 		writer.write(LINE_SEPARATOR);
465 	}
466 
467 	/**
468 	 * Merge the given sets in the following way. All cookies from
469 	 * {@code cookies1} and {@code cookies2} are contained in the resulting set
470 	 * which have unique names. If there is a duplicate entry for one name only
471 	 * the entry from set {@code cookies1} ends up in the resulting set.
472 	 *
473 	 * @param cookies1
474 	 *            first set of cookies
475 	 * @param cookies2
476 	 *            second set of cookies
477 	 *
478 	 * @return the merged cookies
479 	 */
480 	static Set<HttpCookie> mergeCookies(Set<HttpCookie> cookies1,
481 			@Nullable Set<HttpCookie> cookies2) {
482 		Set<HttpCookie> mergedCookies = new LinkedHashSet<>(cookies1);
483 		if (cookies2 != null) {
484 			mergedCookies.addAll(cookies2);
485 		}
486 		return mergedCookies;
487 	}
488 }