View Javadoc
1   /*
2    * Copyright (C) 2019, Matthias Sohn <matthias.sohn@sap.com>
3    * and other copyright owners as documented in the project's IP log.
4    *
5    * This program and the accompanying materials are made available
6    * under the terms of the Eclipse Distribution License v1.0 which
7    * accompanies this distribution, is reproduced below, and is
8    * available at http://www.eclipse.org/org/documents/edl-v10.php
9    *
10   * All rights reserved.
11   *
12   * Redistribution and use in source and binary forms, with or
13   * without modification, are permitted provided that the following
14   * conditions are met:
15   *
16   * - Redistributions of source code must retain the above copyright
17   *   notice, this list of conditions and the following disclaimer.
18   *
19   * - Redistributions in binary form must reproduce the above
20   *   copyright notice, this list of conditions and the following
21   *   disclaimer in the documentation and/or other materials provided
22   *   with the distribution.
23   *
24   * - Neither the name of the Eclipse Foundation, Inc. nor the
25   *   names of its contributors may be used to endorse or promote
26   *   products derived from this software without specific prior
27   *   written permission.
28   *
29   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
30   * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
31   * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
32   * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
33   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
34   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
35   * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
36   * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
37   * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
38   * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
39   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
40   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
41   * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
42   */
43  package org.eclipse.jgit.internal.storage.file;
44  
45  import static org.junit.Assert.assertEquals;
46  import static org.junit.Assert.assertFalse;
47  import static org.junit.Assert.assertTrue;
48  import static org.junit.Assume.assumeFalse;
49  import static org.junit.Assume.assumeTrue;
50  
51  import java.io.File;
52  import java.io.IOException;
53  import java.io.OutputStream;
54  import java.io.Writer;
55  import java.nio.file.Files;
56  import java.nio.file.Path;
57  import java.nio.file.Paths;
58  import java.nio.file.StandardCopyOption;
59  import java.nio.file.StandardOpenOption;
60  //import java.nio.file.attribute.BasicFileAttributes;
61  import java.text.ParseException;
62  import java.time.Instant;
63  import java.util.Collection;
64  import java.util.Iterator;
65  import java.util.Random;
66  import java.util.zip.Deflater;
67  
68  import org.eclipse.jgit.api.GarbageCollectCommand;
69  import org.eclipse.jgit.api.Git;
70  import org.eclipse.jgit.api.errors.AbortedByHookException;
71  import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
72  import org.eclipse.jgit.api.errors.GitAPIException;
73  import org.eclipse.jgit.api.errors.NoFilepatternException;
74  import org.eclipse.jgit.api.errors.NoHeadException;
75  import org.eclipse.jgit.api.errors.NoMessageException;
76  import org.eclipse.jgit.api.errors.UnmergedPathsException;
77  import org.eclipse.jgit.api.errors.WrongRepositoryStateException;
78  import org.eclipse.jgit.junit.RepositoryTestCase;
79  import org.eclipse.jgit.lib.AnyObjectId;
80  import org.eclipse.jgit.lib.ConfigConstants;
81  import org.eclipse.jgit.lib.ObjectId;
82  import org.eclipse.jgit.storage.file.FileBasedConfig;
83  import org.eclipse.jgit.storage.pack.PackConfig;
84  import org.eclipse.jgit.util.FS;
85  import org.junit.Test;
86  
87  public class PackFileSnapshotTest extends RepositoryTestCase {
88  
89  	private static ObjectId unknownID = ObjectId
90  			.fromString("1234567890123456789012345678901234567890");
91  
92  	@Test
93  	public void testSamePackDifferentCompressionDetectChecksumChanged()
94  			throws Exception {
95  		Git git = Git.wrap(db);
96  		File f = writeTrashFile("file", "foobar ");
97  		for (int i = 0; i < 10; i++) {
98  			appendRandomLine(f);
99  			git.add().addFilepattern("file").call();
100 			git.commit().setMessage("message" + i).call();
101 		}
102 
103 		FileBasedConfig c = db.getConfig();
104 		c.setInt(ConfigConstants.CONFIG_GC_SECTION, null,
105 				ConfigConstants.CONFIG_KEY_AUTOPACKLIMIT, 1);
106 		c.save();
107 		Collection<PackFile> packs = gc(Deflater.NO_COMPRESSION);
108 		assertEquals("expected 1 packfile after gc", 1, packs.size());
109 		PackFile p1 = packs.iterator().next();
110 		PackFileSnapshot snapshot = p1.getFileSnapshot();
111 
112 		packs = gc(Deflater.BEST_COMPRESSION);
113 		assertEquals("expected 1 packfile after gc", 1, packs.size());
114 		PackFile p2 = packs.iterator().next();
115 		File pf = p2.getPackFile();
116 
117 		// changing compression level with aggressive gc may change size,
118 		// fileKey (on *nix) and checksum. Hence FileSnapshot.isModified can
119 		// return true already based on size or fileKey.
120 		// So the only thing we can test here is that we ensure that checksum
121 		// also changed when we read it here in this test
122 		assertTrue("expected snapshot to detect modified pack",
123 				snapshot.isModified(pf));
124 		assertTrue("expected checksum changed", snapshot.isChecksumChanged(pf));
125 	}
126 
127 	private void appendRandomLine(File f, int length, Random r)
128 			throws IOException {
129 		try (Writer w = Files.newBufferedWriter(f.toPath(),
130 				StandardOpenOption.APPEND)) {
131 			appendRandomLine(w, length, r);
132 		}
133 	}
134 
135 	private void appendRandomLine(File f) throws IOException {
136 		appendRandomLine(f, 5, new Random());
137 	}
138 
139 	private void appendRandomLine(Writer w, int len, Random r)
140 			throws IOException {
141 		final int c1 = 32; // ' '
142 		int c2 = 126; // '~'
143 		for (int i = 0; i < len; i++) {
144 			w.append((char) (c1 + r.nextInt(1 + c2 - c1)));
145 		}
146 	}
147 
148 	private ObjectId createTestRepo(int testDataSeed, int testDataLength)
149 			throws IOException, GitAPIException, NoFilepatternException,
150 			NoHeadException, NoMessageException, UnmergedPathsException,
151 			ConcurrentRefUpdateException, WrongRepositoryStateException,
152 			AbortedByHookException {
153 		// Create a repo with two commits and one file. Each commit adds
154 		// testDataLength number of bytes. Data are random bytes. Since the
155 		// seed for the random number generator is specified we will get
156 		// the same set of bytes for every run and for every platform
157 		Random r = new Random(testDataSeed);
158 		Git git = Git.wrap(db);
159 		File f = writeTrashFile("file", "foobar ");
160 		appendRandomLine(f, testDataLength, r);
161 		git.add().addFilepattern("file").call();
162 		git.commit().setMessage("message1").call();
163 		appendRandomLine(f, testDataLength, r);
164 		git.add().addFilepattern("file").call();
165 		return git.commit().setMessage("message2").call().getId();
166 	}
167 
168 	// Try repacking so fast that you get two new packs which differ only in
169 	// content/chksum but have same name, size and lastmodified.
170 	// Since this is done with standard gc (which creates new tmp files and
171 	// renames them) the filekeys of the new packfiles differ helping jgit
172 	// to detect the fast modification
173 	@Test
174 	public void testDetectModificationAlthoughSameSizeAndModificationtime()
175 			throws Exception {
176 		int testDataSeed = 1;
177 		int testDataLength = 100;
178 		FileBasedConfig config = db.getConfig();
179 		// don't use mtime of the parent folder to detect pack file
180 		// modification.
181 		config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null,
182 				ConfigConstants.CONFIG_KEY_TRUSTFOLDERSTAT, false);
183 		config.save();
184 
185 		createTestRepo(testDataSeed, testDataLength);
186 
187 		// repack to create initial packfile
188 		PackFile pf = repackAndCheck(5, null, null, null);
189 		Path packFilePath = pf.getPackFile().toPath();
190 		AnyObjectId chk1 = pf.getPackChecksum();
191 		String name = pf.getPackName();
192 		Long length = Long.valueOf(pf.getPackFile().length());
193 		FS fs = db.getFS();
194 		Instant m1 = fs.lastModifiedInstant(packFilePath);
195 
196 		// Wait for a filesystem timer tick to enhance probability the rest of
197 		// this test is done before the filesystem timer ticks again.
198 		fsTick(packFilePath.toFile());
199 
200 		// Repack to create packfile with same name, length. Lastmodified and
201 		// content and checksum are different since compression level differs
202 		AnyObjectId chk2 = repackAndCheck(6, name, length, chk1)
203 				.getPackChecksum();
204 		Instant m2 = fs.lastModifiedInstant(packFilePath);
205 		assumeFalse(m2.equals(m1));
206 
207 		// Repack to create packfile with same name, length. Lastmodified is
208 		// equal to the previous one because we are in the same filesystem timer
209 		// slot. Content and its checksum are different
210 		AnyObjectId chk3 = repackAndCheck(7, name, length, chk2)
211 				.getPackChecksum();
212 		Instant m3 = fs.lastModifiedInstant(packFilePath);
213 
214 		// ask for an unknown git object to force jgit to rescan the list of
215 		// available packs. If we would ask for a known objectid then JGit would
216 		// skip searching for new/modified packfiles
217 		db.getObjectDatabase().has(unknownID);
218 		assertEquals(chk3, getSinglePack(db.getObjectDatabase().getPacks())
219 				.getPackChecksum());
220 		assumeTrue(m3.equals(m2));
221 	}
222 
223 	// Try repacking so fast that we get two new packs which differ only in
224 	// content and checksum but have same name, size and lastmodified.
225 	// To avoid that JGit detects modification by checking the filekey create
226 	// two new packfiles upfront and create copies of them. Then modify the
227 	// packfiles in-place by opening them for write and then copying the
228 	// content.
229 	@Test
230 	public void testDetectModificationAlthoughSameSizeAndModificationtimeAndFileKey()
231 			throws Exception {
232 		int testDataSeed = 1;
233 		int testDataLength = 100;
234 		FileBasedConfig config = db.getConfig();
235 		config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null,
236 				ConfigConstants.CONFIG_KEY_TRUSTFOLDERSTAT, false);
237 		config.save();
238 
239 		createTestRepo(testDataSeed, testDataLength);
240 
241 		// Repack to create initial packfile. Make a copy of it
242 		PackFile pf = repackAndCheck(5, null, null, null);
243 		Path packFilePath = pf.getPackFile().toPath();
244 		Path packFileBasePath = packFilePath.resolveSibling(
245 				packFilePath.getFileName().toString().replaceAll(".pack", ""));
246 		AnyObjectId chk1 = pf.getPackChecksum();
247 		String name = pf.getPackName();
248 		Long length = Long.valueOf(pf.getPackFile().length());
249 		copyPack(packFileBasePath, "", ".copy1");
250 
251 		// Repack to create second packfile. Make a copy of it
252 		AnyObjectId chk2 = repackAndCheck(6, name, length, chk1)
253 				.getPackChecksum();
254 		copyPack(packFileBasePath, "", ".copy2");
255 
256 		// Repack to create third packfile
257 		AnyObjectId chk3 = repackAndCheck(7, name, length, chk2)
258 				.getPackChecksum();
259 		FS fs = db.getFS();
260 		Instant m3 = fs.lastModifiedInstant(packFilePath);
261 		db.getObjectDatabase().has(unknownID);
262 		assertEquals(chk3, getSinglePack(db.getObjectDatabase().getPacks())
263 				.getPackChecksum());
264 
265 		// Wait for a filesystem timer tick to enhance probability the rest of
266 		// this test is done before the filesystem timer ticks.
267 		fsTick(packFilePath.toFile());
268 
269 		// Copy copy2 to packfile data to force modification of packfile without
270 		// changing the packfile's filekey.
271 		copyPack(packFileBasePath, ".copy2", "");
272 		Instant m2 = fs.lastModifiedInstant(packFilePath);
273 		assumeFalse(m3.equals(m2));
274 
275 		db.getObjectDatabase().has(unknownID);
276 		assertEquals(chk2, getSinglePack(db.getObjectDatabase().getPacks())
277 				.getPackChecksum());
278 
279 		// Copy copy2 to packfile data to force modification of packfile without
280 		// changing the packfile's filekey.
281 		copyPack(packFileBasePath, ".copy1", "");
282 		Instant m1 = fs.lastModifiedInstant(packFilePath);
283 		assumeTrue(m2.equals(m1));
284 		db.getObjectDatabase().has(unknownID);
285 		assertEquals(chk1, getSinglePack(db.getObjectDatabase().getPacks())
286 				.getPackChecksum());
287 	}
288 
289 	// Copy file from src to dst but avoid creating a new File (with new
290 	// FileKey) if dst already exists
291 	private Path copyFile(Path src, Path dst) throws IOException {
292 		if (Files.exists(dst)) {
293 			dst.toFile().setWritable(true);
294 			try (OutputStream dstOut = Files.newOutputStream(dst)) {
295 				Files.copy(src, dstOut);
296 				return dst;
297 			}
298 		} else {
299 			return Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
300 		}
301 	}
302 
303 	private Path copyPack(Path base, String srcSuffix, String dstSuffix)
304 			throws IOException {
305 		copyFile(Paths.get(base + ".idx" + srcSuffix),
306 				Paths.get(base + ".idx" + dstSuffix));
307 		copyFile(Paths.get(base + ".bitmap" + srcSuffix),
308 				Paths.get(base + ".bitmap" + dstSuffix));
309 		return copyFile(Paths.get(base + ".pack" + srcSuffix),
310 				Paths.get(base + ".pack" + dstSuffix));
311 	}
312 
313 	private PackFile repackAndCheck(int compressionLevel, String oldName,
314 			Long oldLength, AnyObjectId oldChkSum)
315 			throws IOException, ParseException {
316 		PackFile p = getSinglePack(gc(compressionLevel));
317 		File pf = p.getPackFile();
318 		// The following two assumptions should not cause the test to fail. If
319 		// on a certain platform we get packfiles (containing the same git
320 		// objects) where the lengths differ or the checksums don't differ we
321 		// just skip this test. A reason for that could be that compression
322 		// works differently or random number generator works differently. Then
323 		// we have to search for more consistent test data or checkin these
324 		// packfiles as test resources
325 		assumeTrue(oldLength == null || pf.length() == oldLength.longValue());
326 		assumeTrue(oldChkSum == null || !p.getPackChecksum().equals(oldChkSum));
327 		assertTrue(oldName == null || p.getPackName().equals(oldName));
328 		return p;
329 	}
330 
331 	private PackFile getSinglePack(Collection<PackFile> packs) {
332 		Iterator<PackFile> pIt = packs.iterator();
333 		PackFile p = pIt.next();
334 		assertFalse(pIt.hasNext());
335 		return p;
336 	}
337 
338 	private Collection<PackFile> gc(int compressionLevel)
339 			throws IOException, ParseException {
340 		GC gc = new GC(db);
341 		PackConfig pc = new PackConfig(db.getConfig());
342 		pc.setCompressionLevel(compressionLevel);
343 
344 		pc.setSinglePack(true);
345 
346 		// --aggressive
347 		pc.setDeltaSearchWindowSize(
348 				GarbageCollectCommand.DEFAULT_GC_AGGRESSIVE_WINDOW);
349 		pc.setMaxDeltaDepth(GarbageCollectCommand.DEFAULT_GC_AGGRESSIVE_DEPTH);
350 		pc.setReuseObjects(false);
351 
352 		gc.setPackConfig(pc);
353 		gc.setExpireAgeMillis(0);
354 		gc.setPackExpireAgeMillis(0);
355 		return gc.gc();
356 	}
357 
358 }