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