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   *
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 	 */
138 	public NetscapeCookieFile(Path path) {
139 		this(path, new Date());
140 	}
141 
142 	NetscapeCookieFile(Path path, Date creationDate) {
143 		this.path = path;
144 		this.snapshot = FileSnapshot.DIRTY;
145 		this.creationDate = creationDate;
146 	}
147 
148 	/**
149 	 * @return the path to the underlying cookie file
150 	 */
151 	public Path getPath() {
152 		return path;
153 	}
154 
155 	/**
156 	 * @param refresh
157 	 *            if {@code true} updates the list from the underlying cookie
158 	 *            file if it has been modified since the last read otherwise
159 	 *            returns the current transient state. In case the cookie file
160 	 *            has never been read before will always read from the
161 	 *            underlying file disregarding the value of this parameter.
162 	 * @return all cookies (may contain session cookies as well). This does not
163 	 *         return a copy of the list but rather the original one. Every
164 	 *         addition to the returned list can afterwards be persisted via
165 	 *         {@link #write(URL)}. Errors in the underlying file will not lead
166 	 *         to exceptions but rather to an empty set being returned and the
167 	 *         underlying error being logged.
168 	 */
169 	public Set<HttpCookie> getCookies(boolean refresh) {
170 		if (cookies == null || refresh) {
171 			try {
172 				byte[] in = getFileContentIfModified();
173 				Set<HttpCookie> newCookies = parseCookieFile(in, creationDate);
174 				if (cookies != null) {
175 					cookies = mergeCookies(newCookies, cookies);
176 				} else {
177 					cookies = newCookies;
178 				}
179 				return cookies;
180 			} catch (IOException | IllegalArgumentException e) {
181 				LOG.warn(
182 						MessageFormat.format(
183 								JGitText.get().couldNotReadCookieFile, path),
184 						e);
185 				if (cookies == null) {
186 					cookies = new LinkedHashSet<>();
187 				}
188 			}
189 		}
190 		return cookies;
191 
192 	}
193 
194 	/**
195 	 * Parses the given file and extracts all cookie information from it.
196 	 *
197 	 * @param input
198 	 *            the file content to parse
199 	 * @param creationDate
200 	 *            the date for the creation of the cookies (used to calculate
201 	 *            the maxAge based on the expiration date given within the file)
202 	 * @return the set of parsed cookies from the given file (even expired
203 	 *         ones). If there is more than one cookie with the same name in
204 	 *         this file the last one overwrites the first one!
205 	 * @throws IOException
206 	 *             if the given file could not be read for some reason
207 	 * @throws IllegalArgumentException
208 	 *             if the given file does not have a proper format.
209 	 */
210 	private static Set<HttpCookie> parseCookieFile(@NonNull byte[] input,
211 			@NonNull Date creationDate)
212 			throws IOException, IllegalArgumentException {
213 
214 		String decoded = RawParseUtils.decode(StandardCharsets.US_ASCII, input);
215 
216 		Set<HttpCookie> cookies = new LinkedHashSet<>();
217 		try (BufferedReader reader = new BufferedReader(
218 				new StringReader(decoded))) {
219 			String line;
220 			while ((line = reader.readLine()) != null) {
221 				HttpCookie cookie = parseLine(line, creationDate);
222 				if (cookie != null) {
223 					cookies.add(cookie);
224 				}
225 			}
226 		}
227 		return cookies;
228 	}
229 
230 	private static HttpCookie parseLine(@NonNull String line,
231 			@NonNull Date creationDate) {
232 		if (line.isEmpty() || (line.startsWith("#") //$NON-NLS-1$
233 				&& !line.startsWith(HTTP_ONLY_PREAMBLE))) {
234 			return null;
235 		}
236 		String[] cookieLineParts = line.split(COLUMN_SEPARATOR, 7);
237 		if (cookieLineParts == null) {
238 			throw new IllegalArgumentException(MessageFormat
239 					.format(JGitText.get().couldNotFindTabInLine, line));
240 		}
241 		if (cookieLineParts.length < 7) {
242 			throw new IllegalArgumentException(MessageFormat.format(
243 					JGitText.get().couldNotFindSixTabsInLine,
244 					Integer.valueOf(cookieLineParts.length), line));
245 		}
246 		String name = cookieLineParts[5];
247 		String value = cookieLineParts[6];
248 		HttpCookie cookie = new HttpCookie(name, value);
249 
250 		String domain = cookieLineParts[0];
251 		if (domain.startsWith(HTTP_ONLY_PREAMBLE)) {
252 			cookie.setHttpOnly(true);
253 			domain = domain.substring(HTTP_ONLY_PREAMBLE.length());
254 		}
255 		// strip off leading "."
256 		// (https://tools.ietf.org/html/rfc6265#section-5.2.3)
257 		if (domain.startsWith(".")) { //$NON-NLS-1$
258 			domain = domain.substring(1);
259 		}
260 		cookie.setDomain(domain);
261 		// domain evaluation as boolean flag not considered (i.e. always assumed
262 		// to be true)
263 		cookie.setPath(cookieLineParts[2]);
264 		cookie.setSecure(Boolean.parseBoolean(cookieLineParts[3]));
265 
266 		long expires = Long.parseLong(cookieLineParts[4]);
267 		long maxAge = (expires - creationDate.getTime()) / 1000;
268 		if (maxAge <= 0) {
269 			return null; // skip expired cookies
270 		}
271 		cookie.setMaxAge(maxAge);
272 		return cookie;
273 	}
274 
275 	/**
276 	 * Writes all the cookies being maintained in the set being returned by
277 	 * {@link #getCookies(boolean)} to the underlying file.
278 	 *
279 	 * Session-cookies will not be persisted.
280 	 *
281 	 * @param url
282 	 *            url for which to write the cookies (important to derive
283 	 *            default values for non-explicitly set attributes)
284 	 * @throws IOException
285 	 * @throws IllegalArgumentException
286 	 * @throws InterruptedException
287 	 */
288 	public void write(URL url)
289 			throws IllegalArgumentException, IOException, InterruptedException {
290 		try {
291 			byte[] cookieFileContent = getFileContentIfModified();
292 			if (cookieFileContent != null) {
293 				LOG.debug(
294 						"Reading the underlying cookie file '{}' as it has been modified since the last access", //$NON-NLS-1$
295 						path);
296 				// reread new changes if necessary
297 				Set<HttpCookie> cookiesFromFile = NetscapeCookieFile
298 						.parseCookieFile(cookieFileContent, creationDate);
299 				this.cookies = mergeCookies(cookiesFromFile, cookies);
300 			}
301 		} catch (FileNotFoundException e) {
302 			// ignore if file previously did not exist yet!
303 		}
304 
305 		ByteArrayOutputStream output = new ByteArrayOutputStream();
306 		try (Writer writer = new OutputStreamWriter(output,
307 				StandardCharsets.US_ASCII)) {
308 			write(writer, cookies, url, creationDate);
309 		}
310 		LockFile lockFile = new LockFile(path.toFile());
311 		for (int retryCount = 0; retryCount < LOCK_ACQUIRE_MAX_RETRY_COUNT; retryCount++) {
312 			if (lockFile.lock()) {
313 				try {
314 					lockFile.setNeedSnapshot(true);
315 					lockFile.write(output.toByteArray());
316 					if (!lockFile.commit()) {
317 						throw new IOException(MessageFormat.format(
318 								JGitText.get().cannotCommitWriteTo, path));
319 					}
320 				} finally {
321 					lockFile.unlock();
322 				}
323 				return;
324 			}
325 			Thread.sleep(LOCK_ACQUIRE_RETRY_SLEEP);
326 		}
327 		throw new IOException(
328 				MessageFormat.format(JGitText.get().cannotLock, lockFile));
329 
330 	}
331 
332 	/**
333 	 * Read the underying file and return its content but only in case it has
334 	 * been modified since the last access. Internally calculates the hash and
335 	 * maintains {@link FileSnapshot}s to prevent issues described as <a href=
336 	 * "https://github.com/git/git/blob/master/Documentation/technical/racy-git.txt">"Racy
337 	 * Git problem"</a>. Inspired by {@link FileBasedConfig#load()}.
338 	 *
339 	 * @return the file contents in case the file has been modified since the
340 	 *         last access, otherwise {@code null}
341 	 * @throws IOException
342 	 */
343 	private byte[] getFileContentIfModified() throws IOException {
344 		final int maxStaleRetries = 5;
345 		int retries = 0;
346 		File file = getPath().toFile();
347 		if (!file.exists()) {
348 			LOG.warn(MessageFormat.format(JGitText.get().missingCookieFile,
349 					file.getAbsolutePath()));
350 			return new byte[0];
351 		}
352 		while (true) {
353 			final FileSnapshot oldSnapshot = snapshot;
354 			final FileSnapshot newSnapshot = FileSnapshot.save(file);
355 			try {
356 				final byte[] in = IO.readFully(file);
357 				byte[] newHash = hash(in);
358 				if (Arrays.equals(hash, newHash)) {
359 					if (oldSnapshot.equals(newSnapshot)) {
360 						oldSnapshot.setClean(newSnapshot);
361 					} else {
362 						snapshot = newSnapshot;
363 					}
364 				} else {
365 					snapshot = newSnapshot;
366 					hash = newHash;
367 				}
368 				return in;
369 			} catch (FileNotFoundException e) {
370 				throw e;
371 			} catch (IOException e) {
372 				if (FileUtils.isStaleFileHandle(e)
373 						&& retries < maxStaleRetries) {
374 					if (LOG.isDebugEnabled()) {
375 						LOG.debug(MessageFormat.format(
376 								JGitText.get().configHandleIsStale,
377 								Integer.valueOf(retries)), e);
378 					}
379 					retries++;
380 					continue;
381 				}
382 				throw new IOException(MessageFormat
383 						.format(JGitText.get().cannotReadFile, getPath()), e);
384 			}
385 		}
386 
387 	}
388 
389 	private byte[] hash(final byte[] in) {
390 		return Constants.newMessageDigest().digest(in);
391 	}
392 
393 	/**
394 	 * Writes the given cookies to the file in the Netscape Cookie File Format
395 	 * (also used by curl)
396 	 *
397 	 * @param writer
398 	 *            the writer to use to persist the cookies.
399 	 * @param cookies
400 	 *            the cookies to write into the file
401 	 * @param url
402 	 *            the url for which to write the cookie (to derive the default
403 	 *            values for certain cookie attributes)
404 	 * @param creationDate
405 	 *            the date when the cookie has been created. Important for
406 	 *            calculation the cookie expiration time (calculated from
407 	 *            cookie's maxAge and this creation time).
408 	 * @throws IOException
409 	 */
410 	static void write(@NonNull Writer writer,
411 			@NonNull Collection<HttpCookie> cookies, @NonNull URL url,
412 			@NonNull Date creationDate) throws IOException {
413 		for (HttpCookie cookie : cookies) {
414 			writeCookie(writer, cookie, url, creationDate);
415 		}
416 	}
417 
418 	private static void writeCookie(@NonNull Writer writer,
419 			@NonNull HttpCookie cookie, @NonNull URL url,
420 			@NonNull Date creationDate) throws IOException {
421 		if (cookie.getMaxAge() <= 0) {
422 			return; // skip expired cookies
423 		}
424 		String domain = ""; //$NON-NLS-1$
425 		if (cookie.isHttpOnly()) {
426 			domain = HTTP_ONLY_PREAMBLE;
427 		}
428 		if (cookie.getDomain() != null) {
429 			domain += cookie.getDomain();
430 		} else {
431 			domain += url.getHost();
432 		}
433 		writer.write(domain);
434 		writer.write(COLUMN_SEPARATOR);
435 		writer.write("TRUE"); //$NON-NLS-1$
436 		writer.write(COLUMN_SEPARATOR);
437 		String path = cookie.getPath();
438 		if (path == null) {
439 			path = url.getPath();
440 		}
441 		writer.write(path);
442 		writer.write(COLUMN_SEPARATOR);
443 		writer.write(Boolean.toString(cookie.getSecure()).toUpperCase());
444 		writer.write(COLUMN_SEPARATOR);
445 		final String expirationDate;
446 		// whenCreated field is not accessible in HttpCookie
447 		expirationDate = String
448 				.valueOf(creationDate.getTime() + (cookie.getMaxAge() * 1000));
449 		writer.write(expirationDate);
450 		writer.write(COLUMN_SEPARATOR);
451 		writer.write(cookie.getName());
452 		writer.write(COLUMN_SEPARATOR);
453 		writer.write(cookie.getValue());
454 		writer.write(LINE_SEPARATOR);
455 	}
456 
457 	/**
458 	 * Merge the given sets in the following way. All cookies from
459 	 * {@code cookies1} and {@code cookies2} are contained in the resulting set
460 	 * which have unique names. If there is a duplicate entry for one name only
461 	 * the entry from set {@code cookies1} ends up in the resulting set.
462 	 *
463 	 * @param cookies1
464 	 * @param cookies2
465 	 *
466 	 * @return the merged cookies
467 	 */
468 	static Set<HttpCookie> mergeCookies(Set<HttpCookie> cookies1,
469 			@Nullable Set<HttpCookie> cookies2) {
470 		Set<HttpCookie> mergedCookies = new LinkedHashSet<>(cookies1);
471 		if (cookies2 != null) {
472 			mergedCookies.addAll(cookies2);
473 		}
474 		return mergedCookies;
475 	}
476 }