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