/* * Copyright (C) 2018, Konrad Windszus and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at * https://www.eclipse.org/org/documents/edl-v10.php. * * SPDX-License-Identifier: BSD-3-Clause */ package org.eclipse.jgit.internal.transport.http; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.StringReader; import java.io.Writer; import java.net.HttpCookie; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.text.MessageFormat; import java.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashSet; import java.util.Set; import java.util.concurrent.TimeUnit; import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.annotations.Nullable; import org.eclipse.jgit.internal.JGitText; import org.eclipse.jgit.internal.storage.file.FileSnapshot; import org.eclipse.jgit.internal.storage.file.LockFile; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.storage.file.FileBasedConfig; import org.eclipse.jgit.util.FileUtils; import org.eclipse.jgit.util.IO; import org.eclipse.jgit.util.RawParseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Wraps all cookies persisted in a Netscape Cookie File Format * being referenced via the git config http.cookieFile. *

* It will only load the cookies lazily, i.e. before calling * {@link #getCookies(boolean)} the file is not evaluated. This class also * allows persisting cookies in that file format. *

* In general this class is not thread-safe. So any consumer needs to take care * of synchronization! * * @see Cookie file format * @see Netscape Cookie File * Format * @see Cookie * format for wget * @see libcurl * Cookie file parsing * @see libcurl * Cookie file writing * @see NetscapeCookieFileCache */ public final class NetscapeCookieFile { private static final String HTTP_ONLY_PREAMBLE = "#HttpOnly_"; //$NON-NLS-1$ private static final String COLUMN_SEPARATOR = "\t"; //$NON-NLS-1$ private static final String LINE_SEPARATOR = "\n"; //$NON-NLS-1$ /** * Maximum number of retries to acquire the lock for writing to the * underlying file. */ private static final int LOCK_ACQUIRE_MAX_RETRY_COUNT = 4; /** * Sleep time in milliseconds between retries to acquire the lock for * writing to the underlying file. */ private static final int LOCK_ACQUIRE_RETRY_SLEEP = 500; private final Path path; private FileSnapshot snapshot; private byte[] hash; private final Instant createdAt; private Set cookies = null; private static final Logger LOG = LoggerFactory .getLogger(NetscapeCookieFile.class); /** * @param path * where to find the cookie file */ public NetscapeCookieFile(Path path) { this(path, Instant.now()); } NetscapeCookieFile(Path path, Instant createdAt) { this.path = path; this.snapshot = FileSnapshot.DIRTY; this.createdAt = createdAt; } /** * Path to the underlying cookie file. * * @return the path */ public Path getPath() { return path; } /** * Return all cookies from the underlying cookie file. * * @param refresh * if {@code true} updates the list from the underlying cookie * file if it has been modified since the last read otherwise * returns the current transient state. In case the cookie file * has never been read before will always read from the * underlying file disregarding the value of this parameter. * @return all cookies (may contain session cookies as well). This does not * return a copy of the list but rather the original one. Every * addition to the returned list can afterwards be persisted via * {@link #write(URL)}. Errors in the underlying file will not lead * to exceptions but rather to an empty set being returned and the * underlying error being logged. */ public Set getCookies(boolean refresh) { if (cookies == null || refresh) { try { byte[] in = getFileContentIfModified(); Set newCookies = parseCookieFile(in, createdAt); if (cookies != null) { cookies = mergeCookies(newCookies, cookies); } else { cookies = newCookies; } return cookies; } catch (IOException | IllegalArgumentException e) { LOG.warn( MessageFormat.format( JGitText.get().couldNotReadCookieFile, path), e); if (cookies == null) { cookies = new LinkedHashSet<>(); } } } return cookies; } /** * Parses the given file and extracts all cookie information from it. * * @param input * the file content to parse * @param createdAt * cookie creation time; used to calculate the maxAge based on * the expiration date given within the file * @return the set of parsed cookies from the given file (even expired * ones). If there is more than one cookie with the same name in * this file the last one overwrites the first one! * @throws IOException * if the given file could not be read for some reason * @throws IllegalArgumentException * if the given file does not have a proper format */ private static Set parseCookieFile(@NonNull byte[] input, @NonNull Instant createdAt) throws IOException, IllegalArgumentException { String decoded = RawParseUtils.decode(StandardCharsets.US_ASCII, input); Set cookies = new LinkedHashSet<>(); try (BufferedReader reader = new BufferedReader( new StringReader(decoded))) { String line; while ((line = reader.readLine()) != null) { HttpCookie cookie = parseLine(line, createdAt); if (cookie != null) { cookies.add(cookie); } } } return cookies; } private static HttpCookie parseLine(@NonNull String line, @NonNull Instant createdAt) { if (line.isEmpty() || (line.startsWith("#") //$NON-NLS-1$ && !line.startsWith(HTTP_ONLY_PREAMBLE))) { return null; } String[] cookieLineParts = line.split(COLUMN_SEPARATOR, 7); if (cookieLineParts == null) { throw new IllegalArgumentException(MessageFormat .format(JGitText.get().couldNotFindTabInLine, line)); } if (cookieLineParts.length < 7) { throw new IllegalArgumentException(MessageFormat.format( JGitText.get().couldNotFindSixTabsInLine, Integer.valueOf(cookieLineParts.length), line)); } String name = cookieLineParts[5]; String value = cookieLineParts[6]; HttpCookie cookie = new HttpCookie(name, value); String domain = cookieLineParts[0]; if (domain.startsWith(HTTP_ONLY_PREAMBLE)) { cookie.setHttpOnly(true); domain = domain.substring(HTTP_ONLY_PREAMBLE.length()); } // strip off leading "." // (https://tools.ietf.org/html/rfc6265#section-5.2.3) if (domain.startsWith(".")) { //$NON-NLS-1$ domain = domain.substring(1); } cookie.setDomain(domain); // domain evaluation as boolean flag not considered (i.e. always assumed // to be true) cookie.setPath(cookieLineParts[2]); cookie.setSecure(Boolean.parseBoolean(cookieLineParts[3])); long expires = Long.parseLong(cookieLineParts[4]); // Older versions stored milliseconds. This heuristic to detect that // will cause trouble in the year 33658. :-) if (cookieLineParts[4].length() == 13) { expires = TimeUnit.MILLISECONDS.toSeconds(expires); } long maxAge = expires - createdAt.getEpochSecond(); if (maxAge <= 0) { return null; // skip expired cookies } cookie.setMaxAge(maxAge); return cookie; } /** * Read the underlying file and return its content but only in case it has * been modified since the last access. *

* Internally calculates the hash and maintains {@link FileSnapshot}s to * prevent issues described as "Racy * Git problem". Inspired by {@link FileBasedConfig#load()}. * * @return the file contents in case the file has been modified since the * last access, otherwise {@code null} * @throws IOException * if the file is not found or cannot be read */ private byte[] getFileContentIfModified() throws IOException { final int maxStaleRetries = 5; int retries = 0; File file = getPath().toFile(); if (!file.exists()) { LOG.warn(MessageFormat.format(JGitText.get().missingCookieFile, file.getAbsolutePath())); return new byte[0]; } while (true) { final FileSnapshot oldSnapshot = snapshot; final FileSnapshot newSnapshot = FileSnapshot.save(file); try { final byte[] in = IO.readFully(file); byte[] newHash = hash(in); if (Arrays.equals(hash, newHash)) { if (oldSnapshot.equals(newSnapshot)) { oldSnapshot.setClean(newSnapshot); } else { snapshot = newSnapshot; } } else { snapshot = newSnapshot; hash = newHash; } return in; } catch (FileNotFoundException e) { throw e; } catch (IOException e) { if (FileUtils.isStaleFileHandle(e) && retries < maxStaleRetries) { if (LOG.isDebugEnabled()) { LOG.debug(MessageFormat.format( JGitText.get().configHandleIsStale, Integer.valueOf(retries)), e); } retries++; continue; } throw new IOException(MessageFormat .format(JGitText.get().cannotReadFile, getPath()), e); } } } private static byte[] hash(final byte[] in) { return Constants.newMessageDigest().digest(in); } /** * Writes all the cookies being maintained in the set being returned by * {@link #getCookies(boolean)} to the underlying file. *

* Session-cookies will not be persisted. * * @param url * url for which to write the cookies (important to derive * default values for non-explicitly set attributes) * @throws IOException * if the underlying cookie file could not be read or written or * a problem with the lock file * @throws InterruptedException * if the thread is interrupted while waiting for the lock */ public void write(URL url) throws IOException, InterruptedException { try { byte[] cookieFileContent = getFileContentIfModified(); if (cookieFileContent != null) { LOG.debug("Reading the underlying cookie file '{}' " //$NON-NLS-1$ + "as it has been modified since " //$NON-NLS-1$ + "the last access", //$NON-NLS-1$ path); // reread new changes if necessary Set cookiesFromFile = NetscapeCookieFile .parseCookieFile(cookieFileContent, createdAt); this.cookies = mergeCookies(cookiesFromFile, cookies); } } catch (FileNotFoundException e) { // ignore if file previously did not exist yet! } ByteArrayOutputStream output = new ByteArrayOutputStream(); try (Writer writer = new OutputStreamWriter(output, StandardCharsets.US_ASCII)) { write(writer, cookies, url, createdAt); } LockFile lockFile = new LockFile(path.toFile()); for (int retryCount = 0; retryCount < LOCK_ACQUIRE_MAX_RETRY_COUNT; retryCount++) { if (lockFile.lock()) { try { lockFile.setNeedSnapshot(true); lockFile.write(output.toByteArray()); if (!lockFile.commit()) { throw new IOException(MessageFormat.format( JGitText.get().cannotCommitWriteTo, path)); } } finally { lockFile.unlock(); } return; } Thread.sleep(LOCK_ACQUIRE_RETRY_SLEEP); } throw new IOException( MessageFormat.format(JGitText.get().cannotLock, lockFile)); } /** * Writes the given cookies to the file in the Netscape Cookie File Format * (also used by curl). * * @param writer * the writer to use to persist the cookies * @param cookies * the cookies to write into the file * @param url * the url for which to write the cookie (to derive the default * values for certain cookie attributes) * @param createdAt * cookie creation time; used to calculate a cookie's expiration * time * @throws IOException * if an I/O error occurs */ static void write(@NonNull Writer writer, @NonNull Collection cookies, @NonNull URL url, @NonNull Instant createdAt) throws IOException { for (HttpCookie cookie : cookies) { writeCookie(writer, cookie, url, createdAt); } } private static void writeCookie(@NonNull Writer writer, @NonNull HttpCookie cookie, @NonNull URL url, @NonNull Instant createdAt) throws IOException { if (cookie.getMaxAge() <= 0) { return; // skip expired cookies } String domain = ""; //$NON-NLS-1$ if (cookie.isHttpOnly()) { domain = HTTP_ONLY_PREAMBLE; } if (cookie.getDomain() != null) { domain += cookie.getDomain(); } else { domain += url.getHost(); } writer.write(domain); writer.write(COLUMN_SEPARATOR); writer.write("TRUE"); //$NON-NLS-1$ writer.write(COLUMN_SEPARATOR); String path = cookie.getPath(); if (path == null) { path = url.getPath(); } writer.write(path); writer.write(COLUMN_SEPARATOR); writer.write(Boolean.toString(cookie.getSecure()).toUpperCase()); writer.write(COLUMN_SEPARATOR); final String expirationDate; // whenCreated field is not accessible in HttpCookie expirationDate = String .valueOf(createdAt.getEpochSecond() + cookie.getMaxAge()); writer.write(expirationDate); writer.write(COLUMN_SEPARATOR); writer.write(cookie.getName()); writer.write(COLUMN_SEPARATOR); writer.write(cookie.getValue()); writer.write(LINE_SEPARATOR); } /** * Merge the given sets in the following way. All cookies from * {@code cookies1} and {@code cookies2} are contained in the resulting set * which have unique names. If there is a duplicate entry for one name only * the entry from set {@code cookies1} ends up in the resulting set. * * @param cookies1 * first set of cookies * @param cookies2 * second set of cookies * * @return the merged cookies */ static Set mergeCookies(Set cookies1, @Nullable Set cookies2) { Set mergedCookies = new LinkedHashSet<>(cookies1); if (cookies2 != null) { mergedCookies.addAll(cookies2); } return mergedCookies; } }