blob: 075f55c733bbe409ffacbf3d5816295c353fd3fe [file] [log] [blame]
/*
* Copyright (C) 2018, Konrad Windszus <konrad_w@gmx.de>
* and other copyright owners as documented in the project's IP log.
*
* This program and the accompanying materials are made available
* under the terms of the Eclipse Distribution License v1.0 which
* accompanies this distribution, is reproduced below, and is
* available at http://www.eclipse.org/org/documents/edl-v10.php
*
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or
* without modification, are permitted provided that the following
* conditions are met:
*
* - Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* - Neither the name of the Eclipse Foundation, Inc. nor the
* names of its contributors may be used to endorse or promote
* products derived from this software without specific prior
* written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
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.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.Set;
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 <strong>Netscape Cookie File Format</strong>
* being referenced via the git config <a href=
* "https://git-scm.com/docs/git-config#git-config-httpcookieFile">http.cookieFile</a>.
*
* 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.
* <p>
* In general this class is not thread-safe. So any consumer needs to take care
* of synchronization!
*
* @see <a href="http://www.cookiecentral.com/faq/#3.5">Netscape Cookie File
* Format</a>
* @see <a href=
* "https://unix.stackexchange.com/questions/36531/format-of-cookies-when-using-wget">Cookie
* format for wget</a>
* @see <a href=
* "https://github.com/curl/curl/blob/07ebaf837843124ee670e5b8c218b80b92e06e47/lib/cookie.c#L745">libcurl
* Cookie file parsing</a>
* @see <a href=
* "https://github.com/curl/curl/blob/07ebaf837843124ee670e5b8c218b80b92e06e47/lib/cookie.c#L1417">libcurl
* Cookie file writing</a>
* @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;
final Date creationDate;
private Set<HttpCookie> cookies = null;
private static final Logger LOG = LoggerFactory
.getLogger(NetscapeCookieFile.class);
/**
* @param path
*/
public NetscapeCookieFile(Path path) {
this(path, new Date());
}
NetscapeCookieFile(Path path, Date creationDate) {
this.path = path;
this.snapshot = FileSnapshot.DIRTY;
this.creationDate = creationDate;
}
/**
* @return the path to the underlying cookie file
*/
public Path getPath() {
return path;
}
/**
* @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<HttpCookie> getCookies(boolean refresh) {
if (cookies == null || refresh) {
try {
byte[] in = getFileContentIfModified();
Set<HttpCookie> newCookies = parseCookieFile(in, creationDate);
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 creationDate
* the date for the creation of the cookies (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<HttpCookie> parseCookieFile(@NonNull byte[] input,
@NonNull Date creationDate)
throws IOException, IllegalArgumentException {
String decoded = RawParseUtils.decode(StandardCharsets.US_ASCII, input);
Set<HttpCookie> cookies = new LinkedHashSet<>();
try (BufferedReader reader = new BufferedReader(
new StringReader(decoded))) {
String line;
while ((line = reader.readLine()) != null) {
HttpCookie cookie = parseLine(line, creationDate);
if (cookie != null) {
cookies.add(cookie);
}
}
}
return cookies;
}
private static HttpCookie parseLine(@NonNull String line,
@NonNull Date creationDate) {
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]);
long maxAge = (expires - creationDate.getTime()) / 1000;
if (maxAge <= 0) {
return null; // skip expired cookies
}
cookie.setMaxAge(maxAge);
return cookie;
}
/**
* 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
* @throws IllegalArgumentException
* @throws InterruptedException
*/
public void write(URL url)
throws IllegalArgumentException, IOException, InterruptedException {
try {
byte[] cookieFileContent = getFileContentIfModified();
if (cookieFileContent != null) {
LOG.debug(
"Reading the underlying cookie file '{}' as it has been modified since the last access", //$NON-NLS-1$
path);
// reread new changes if necessary
Set<HttpCookie> cookiesFromFile = NetscapeCookieFile
.parseCookieFile(cookieFileContent, creationDate);
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, creationDate);
}
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));
}
/**
* Read the underying 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 <a href=
* "https://github.com/git/git/blob/master/Documentation/technical/racy-git.txt">"Racy
* Git problem"</a>. 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
*/
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 byte[] hash(final byte[] in) {
return Constants.newMessageDigest().digest(in);
}
/**
* 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 creationDate
* the date when the cookie has been created. Important for
* calculation the cookie expiration time (calculated from
* cookie's maxAge and this creation time).
* @throws IOException
*/
static void write(@NonNull Writer writer,
@NonNull Collection<HttpCookie> cookies, @NonNull URL url,
@NonNull Date creationDate) throws IOException {
for (HttpCookie cookie : cookies) {
writeCookie(writer, cookie, url, creationDate);
}
}
private static void writeCookie(@NonNull Writer writer,
@NonNull HttpCookie cookie, @NonNull URL url,
@NonNull Date creationDate) 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(creationDate.getTime() + (cookie.getMaxAge() * 1000));
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
* @param cookies2
*
* @return the merged cookies
*/
static Set<HttpCookie> mergeCookies(Set<HttpCookie> cookies1,
@Nullable Set<HttpCookie> cookies2) {
Set<HttpCookie> mergedCookies = new LinkedHashSet<>(cookies1);
if (cookies2 != null) {
mergedCookies.addAll(cookies2);
}
return mergedCookies;
}
}