NetscapeCookieFile.java

  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. import java.io.BufferedReader;
  12. import java.io.ByteArrayOutputStream;
  13. import java.io.File;
  14. import java.io.FileNotFoundException;
  15. import java.io.IOException;
  16. import java.io.OutputStreamWriter;
  17. import java.io.StringReader;
  18. import java.io.Writer;
  19. import java.net.HttpCookie;
  20. import java.net.URL;
  21. import java.nio.charset.StandardCharsets;
  22. import java.nio.file.Path;
  23. import java.text.MessageFormat;
  24. import java.time.Instant;
  25. import java.util.Arrays;
  26. import java.util.Collection;
  27. import java.util.LinkedHashSet;
  28. import java.util.Set;
  29. import java.util.concurrent.TimeUnit;

  30. import org.eclipse.jgit.annotations.NonNull;
  31. import org.eclipse.jgit.annotations.Nullable;
  32. import org.eclipse.jgit.internal.JGitText;
  33. import org.eclipse.jgit.internal.storage.file.FileSnapshot;
  34. import org.eclipse.jgit.internal.storage.file.LockFile;
  35. import org.eclipse.jgit.lib.Constants;
  36. import org.eclipse.jgit.storage.file.FileBasedConfig;
  37. import org.eclipse.jgit.util.FileUtils;
  38. import org.eclipse.jgit.util.IO;
  39. import org.eclipse.jgit.util.RawParseUtils;
  40. import org.slf4j.Logger;
  41. import org.slf4j.LoggerFactory;

  42. /**
  43.  * Wraps all cookies persisted in a <strong>Netscape Cookie File Format</strong>
  44.  * being referenced via the git config <a href=
  45.  * "https://git-scm.com/docs/git-config#git-config-httpcookieFile">http.cookieFile</a>.
  46.  * <p>
  47.  * It will only load the cookies lazily, i.e. before calling
  48.  * {@link #getCookies(boolean)} the file is not evaluated. This class also
  49.  * allows persisting cookies in that file format.
  50.  * <p>
  51.  * In general this class is not thread-safe. So any consumer needs to take care
  52.  * of synchronization!
  53.  *
  54.  * @see <a href="https://curl.se/docs/http-cookies.html">Cookie file format</a>
  55.  * @see <a href="http://www.cookiecentral.com/faq/#3.5">Netscape Cookie File
  56.  *      Format</a>
  57.  * @see <a href=
  58.  *      "https://unix.stackexchange.com/questions/36531/format-of-cookies-when-using-wget">Cookie
  59.  *      format for wget</a>
  60.  * @see <a href=
  61.  *      "https://github.com/curl/curl/blob/07ebaf837843124ee670e5b8c218b80b92e06e47/lib/cookie.c#L745">libcurl
  62.  *      Cookie file parsing</a>
  63.  * @see <a href=
  64.  *      "https://github.com/curl/curl/blob/07ebaf837843124ee670e5b8c218b80b92e06e47/lib/cookie.c#L1417">libcurl
  65.  *      Cookie file writing</a>
  66.  * @see NetscapeCookieFileCache
  67.  */
  68. public final class NetscapeCookieFile {

  69.     private static final String HTTP_ONLY_PREAMBLE = "#HttpOnly_"; //$NON-NLS-1$

  70.     private static final String COLUMN_SEPARATOR = "\t"; //$NON-NLS-1$

  71.     private static final String LINE_SEPARATOR = "\n"; //$NON-NLS-1$

  72.     /**
  73.      * Maximum number of retries to acquire the lock for writing to the
  74.      * underlying file.
  75.      */
  76.     private static final int LOCK_ACQUIRE_MAX_RETRY_COUNT = 4;

  77.     /**
  78.      * Sleep time in milliseconds between retries to acquire the lock for
  79.      * writing to the underlying file.
  80.      */
  81.     private static final int LOCK_ACQUIRE_RETRY_SLEEP = 500;

  82.     private final Path path;

  83.     private FileSnapshot snapshot;

  84.     private byte[] hash;

  85.     private final Instant createdAt;

  86.     private Set<HttpCookie> cookies = null;

  87.     private static final Logger LOG = LoggerFactory
  88.             .getLogger(NetscapeCookieFile.class);

  89.     /**
  90.      * @param path
  91.      *            where to find the cookie file
  92.      */
  93.     public NetscapeCookieFile(Path path) {
  94.         this(path, Instant.now());
  95.     }

  96.     NetscapeCookieFile(Path path, Instant createdAt) {
  97.         this.path = path;
  98.         this.snapshot = FileSnapshot.DIRTY;
  99.         this.createdAt = createdAt;
  100.     }

  101.     /**
  102.      * Path to the underlying cookie file.
  103.      *
  104.      * @return the path
  105.      */
  106.     public Path getPath() {
  107.         return path;
  108.     }

  109.     /**
  110.      * Return all cookies from the underlying cookie file.
  111.      *
  112.      * @param refresh
  113.      *            if {@code true} updates the list from the underlying cookie
  114.      *            file if it has been modified since the last read otherwise
  115.      *            returns the current transient state. In case the cookie file
  116.      *            has never been read before will always read from the
  117.      *            underlying file disregarding the value of this parameter.
  118.      * @return all cookies (may contain session cookies as well). This does not
  119.      *         return a copy of the list but rather the original one. Every
  120.      *         addition to the returned list can afterwards be persisted via
  121.      *         {@link #write(URL)}. Errors in the underlying file will not lead
  122.      *         to exceptions but rather to an empty set being returned and the
  123.      *         underlying error being logged.
  124.      */
  125.     public Set<HttpCookie> getCookies(boolean refresh) {
  126.         if (cookies == null || refresh) {
  127.             try {
  128.                 byte[] in = getFileContentIfModified();
  129.                 Set<HttpCookie> newCookies = parseCookieFile(in, createdAt);
  130.                 if (cookies != null) {
  131.                     cookies = mergeCookies(newCookies, cookies);
  132.                 } else {
  133.                     cookies = newCookies;
  134.                 }
  135.                 return cookies;
  136.             } catch (IOException | IllegalArgumentException e) {
  137.                 LOG.warn(
  138.                         MessageFormat.format(
  139.                                 JGitText.get().couldNotReadCookieFile, path),
  140.                         e);
  141.                 if (cookies == null) {
  142.                     cookies = new LinkedHashSet<>();
  143.                 }
  144.             }
  145.         }
  146.         return cookies;

  147.     }

  148.     /**
  149.      * Parses the given file and extracts all cookie information from it.
  150.      *
  151.      * @param input
  152.      *            the file content to parse
  153.      * @param createdAt
  154.      *            cookie creation time; used to calculate the maxAge based on
  155.      *            the expiration date given within the file
  156.      * @return the set of parsed cookies from the given file (even expired
  157.      *         ones). If there is more than one cookie with the same name in
  158.      *         this file the last one overwrites the first one!
  159.      * @throws IOException
  160.      *             if the given file could not be read for some reason
  161.      * @throws IllegalArgumentException
  162.      *             if the given file does not have a proper format
  163.      */
  164.     private static Set<HttpCookie> parseCookieFile(@NonNull byte[] input,
  165.             @NonNull Instant createdAt)
  166.             throws IOException, IllegalArgumentException {

  167.         String decoded = RawParseUtils.decode(StandardCharsets.US_ASCII, input);

  168.         Set<HttpCookie> cookies = new LinkedHashSet<>();
  169.         try (BufferedReader reader = new BufferedReader(
  170.                 new StringReader(decoded))) {
  171.             String line;
  172.             while ((line = reader.readLine()) != null) {
  173.                 HttpCookie cookie = parseLine(line, createdAt);
  174.                 if (cookie != null) {
  175.                     cookies.add(cookie);
  176.                 }
  177.             }
  178.         }
  179.         return cookies;
  180.     }

  181.     private static HttpCookie parseLine(@NonNull String line,
  182.             @NonNull Instant createdAt) {
  183.         if (line.isEmpty() || (line.startsWith("#") //$NON-NLS-1$
  184.                 && !line.startsWith(HTTP_ONLY_PREAMBLE))) {
  185.             return null;
  186.         }
  187.         String[] cookieLineParts = line.split(COLUMN_SEPARATOR, 7);
  188.         if (cookieLineParts == null) {
  189.             throw new IllegalArgumentException(MessageFormat
  190.                     .format(JGitText.get().couldNotFindTabInLine, line));
  191.         }
  192.         if (cookieLineParts.length < 7) {
  193.             throw new IllegalArgumentException(MessageFormat.format(
  194.                     JGitText.get().couldNotFindSixTabsInLine,
  195.                     Integer.valueOf(cookieLineParts.length), line));
  196.         }
  197.         String name = cookieLineParts[5];
  198.         String value = cookieLineParts[6];
  199.         HttpCookie cookie = new HttpCookie(name, value);

  200.         String domain = cookieLineParts[0];
  201.         if (domain.startsWith(HTTP_ONLY_PREAMBLE)) {
  202.             cookie.setHttpOnly(true);
  203.             domain = domain.substring(HTTP_ONLY_PREAMBLE.length());
  204.         }
  205.         // strip off leading "."
  206.         // (https://tools.ietf.org/html/rfc6265#section-5.2.3)
  207.         if (domain.startsWith(".")) { //$NON-NLS-1$
  208.             domain = domain.substring(1);
  209.         }
  210.         cookie.setDomain(domain);
  211.         // domain evaluation as boolean flag not considered (i.e. always assumed
  212.         // to be true)
  213.         cookie.setPath(cookieLineParts[2]);
  214.         cookie.setSecure(Boolean.parseBoolean(cookieLineParts[3]));

  215.         long expires = Long.parseLong(cookieLineParts[4]);
  216.         // Older versions stored milliseconds. This heuristic to detect that
  217.         // will cause trouble in the year 33658. :-)
  218.         if (cookieLineParts[4].length() == 13) {
  219.             expires = TimeUnit.MILLISECONDS.toSeconds(expires);
  220.         }
  221.         long maxAge = expires - createdAt.getEpochSecond();
  222.         if (maxAge <= 0) {
  223.             return null; // skip expired cookies
  224.         }
  225.         cookie.setMaxAge(maxAge);
  226.         return cookie;
  227.     }

  228.     /**
  229.      * Read the underlying file and return its content but only in case it has
  230.      * been modified since the last access.
  231.      * <p>
  232.      * Internally calculates the hash and maintains {@link FileSnapshot}s to
  233.      * prevent issues described as <a href=
  234.      * "https://github.com/git/git/blob/master/Documentation/technical/racy-git.txt">"Racy
  235.      * Git problem"</a>. Inspired by {@link FileBasedConfig#load()}.
  236.      *
  237.      * @return the file contents in case the file has been modified since the
  238.      *         last access, otherwise {@code null}
  239.      * @throws IOException
  240.      *             if the file is not found or cannot be read
  241.      */
  242.     private byte[] getFileContentIfModified() throws IOException {
  243.         final int maxStaleRetries = 5;
  244.         int retries = 0;
  245.         File file = getPath().toFile();
  246.         if (!file.exists()) {
  247.             LOG.warn(MessageFormat.format(JGitText.get().missingCookieFile,
  248.                     file.getAbsolutePath()));
  249.             return new byte[0];
  250.         }
  251.         while (true) {
  252.             final FileSnapshot oldSnapshot = snapshot;
  253.             final FileSnapshot newSnapshot = FileSnapshot.save(file);
  254.             try {
  255.                 final byte[] in = IO.readFully(file);
  256.                 byte[] newHash = hash(in);
  257.                 if (Arrays.equals(hash, newHash)) {
  258.                     if (oldSnapshot.equals(newSnapshot)) {
  259.                         oldSnapshot.setClean(newSnapshot);
  260.                     } else {
  261.                         snapshot = newSnapshot;
  262.                     }
  263.                 } else {
  264.                     snapshot = newSnapshot;
  265.                     hash = newHash;
  266.                 }
  267.                 return in;
  268.             } catch (FileNotFoundException e) {
  269.                 throw e;
  270.             } catch (IOException e) {
  271.                 if (FileUtils.isStaleFileHandle(e)
  272.                         && retries < maxStaleRetries) {
  273.                     if (LOG.isDebugEnabled()) {
  274.                         LOG.debug(MessageFormat.format(
  275.                                 JGitText.get().configHandleIsStale,
  276.                                 Integer.valueOf(retries)), e);
  277.                     }
  278.                     retries++;
  279.                     continue;
  280.                 }
  281.                 throw new IOException(MessageFormat
  282.                         .format(JGitText.get().cannotReadFile, getPath()), e);
  283.             }
  284.         }

  285.     }

  286.     private static byte[] hash(final byte[] in) {
  287.         return Constants.newMessageDigest().digest(in);
  288.     }

  289.     /**
  290.      * Writes all the cookies being maintained in the set being returned by
  291.      * {@link #getCookies(boolean)} to the underlying file.
  292.      * <p>
  293.      * Session-cookies will not be persisted.
  294.      *
  295.      * @param url
  296.      *            url for which to write the cookies (important to derive
  297.      *            default values for non-explicitly set attributes)
  298.      * @throws IOException
  299.      *             if the underlying cookie file could not be read or written or
  300.      *             a problem with the lock file
  301.      * @throws InterruptedException
  302.      *             if the thread is interrupted while waiting for the lock
  303.      */
  304.     public void write(URL url) throws IOException, InterruptedException {
  305.         try {
  306.             byte[] cookieFileContent = getFileContentIfModified();
  307.             if (cookieFileContent != null) {
  308.                 LOG.debug("Reading the underlying cookie file '{}' " //$NON-NLS-1$
  309.                         + "as it has been modified since " //$NON-NLS-1$
  310.                         + "the last access", //$NON-NLS-1$
  311.                         path);
  312.                 // reread new changes if necessary
  313.                 Set<HttpCookie> cookiesFromFile = NetscapeCookieFile
  314.                         .parseCookieFile(cookieFileContent, createdAt);
  315.                 this.cookies = mergeCookies(cookiesFromFile, cookies);
  316.             }
  317.         } catch (FileNotFoundException e) {
  318.             // ignore if file previously did not exist yet!
  319.         }

  320.         ByteArrayOutputStream output = new ByteArrayOutputStream();
  321.         try (Writer writer = new OutputStreamWriter(output,
  322.                 StandardCharsets.US_ASCII)) {
  323.             write(writer, cookies, url, createdAt);
  324.         }
  325.         LockFile lockFile = new LockFile(path.toFile());
  326.         for (int retryCount = 0; retryCount < LOCK_ACQUIRE_MAX_RETRY_COUNT; retryCount++) {
  327.             if (lockFile.lock()) {
  328.                 try {
  329.                     lockFile.setNeedSnapshot(true);
  330.                     lockFile.write(output.toByteArray());
  331.                     if (!lockFile.commit()) {
  332.                         throw new IOException(MessageFormat.format(
  333.                                 JGitText.get().cannotCommitWriteTo, path));
  334.                     }
  335.                 } finally {
  336.                     lockFile.unlock();
  337.                 }
  338.                 return;
  339.             }
  340.             Thread.sleep(LOCK_ACQUIRE_RETRY_SLEEP);
  341.         }
  342.         throw new IOException(
  343.                 MessageFormat.format(JGitText.get().cannotLock, lockFile));
  344.     }

  345.     /**
  346.      * Writes the given cookies to the file in the Netscape Cookie File Format
  347.      * (also used by curl).
  348.      *
  349.      * @param writer
  350.      *            the writer to use to persist the cookies
  351.      * @param cookies
  352.      *            the cookies to write into the file
  353.      * @param url
  354.      *            the url for which to write the cookie (to derive the default
  355.      *            values for certain cookie attributes)
  356.      * @param createdAt
  357.      *            cookie creation time; used to calculate a cookie's expiration
  358.      *            time
  359.      * @throws IOException
  360.      *             if an I/O error occurs
  361.      */
  362.     static void write(@NonNull Writer writer,
  363.             @NonNull Collection<HttpCookie> cookies, @NonNull URL url,
  364.             @NonNull Instant createdAt) throws IOException {
  365.         for (HttpCookie cookie : cookies) {
  366.             writeCookie(writer, cookie, url, createdAt);
  367.         }
  368.     }

  369.     private static void writeCookie(@NonNull Writer writer,
  370.             @NonNull HttpCookie cookie, @NonNull URL url,
  371.             @NonNull Instant createdAt) throws IOException {
  372.         if (cookie.getMaxAge() <= 0) {
  373.             return; // skip expired cookies
  374.         }
  375.         String domain = ""; //$NON-NLS-1$
  376.         if (cookie.isHttpOnly()) {
  377.             domain = HTTP_ONLY_PREAMBLE;
  378.         }
  379.         if (cookie.getDomain() != null) {
  380.             domain += cookie.getDomain();
  381.         } else {
  382.             domain += url.getHost();
  383.         }
  384.         writer.write(domain);
  385.         writer.write(COLUMN_SEPARATOR);
  386.         writer.write("TRUE"); //$NON-NLS-1$
  387.         writer.write(COLUMN_SEPARATOR);
  388.         String path = cookie.getPath();
  389.         if (path == null) {
  390.             path = url.getPath();
  391.         }
  392.         writer.write(path);
  393.         writer.write(COLUMN_SEPARATOR);
  394.         writer.write(Boolean.toString(cookie.getSecure()).toUpperCase());
  395.         writer.write(COLUMN_SEPARATOR);
  396.         final String expirationDate;
  397.         // whenCreated field is not accessible in HttpCookie
  398.         expirationDate = String
  399.                 .valueOf(createdAt.getEpochSecond() + cookie.getMaxAge());
  400.         writer.write(expirationDate);
  401.         writer.write(COLUMN_SEPARATOR);
  402.         writer.write(cookie.getName());
  403.         writer.write(COLUMN_SEPARATOR);
  404.         writer.write(cookie.getValue());
  405.         writer.write(LINE_SEPARATOR);
  406.     }

  407.     /**
  408.      * Merge the given sets in the following way. All cookies from
  409.      * {@code cookies1} and {@code cookies2} are contained in the resulting set
  410.      * which have unique names. If there is a duplicate entry for one name only
  411.      * the entry from set {@code cookies1} ends up in the resulting set.
  412.      *
  413.      * @param cookies1
  414.      *            first set of cookies
  415.      * @param cookies2
  416.      *            second set of cookies
  417.      *
  418.      * @return the merged cookies
  419.      */
  420.     static Set<HttpCookie> mergeCookies(Set<HttpCookie> cookies1,
  421.             @Nullable Set<HttpCookie> cookies2) {
  422.         Set<HttpCookie> mergedCookies = new LinkedHashSet<>(cookies1);
  423.         if (cookies2 != null) {
  424.             mergedCookies.addAll(cookies2);
  425.         }
  426.         return mergedCookies;
  427.     }
  428. }