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