You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

NetscapeCookieFile.java 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  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. }