View Javadoc
1   /*
2    *    Copyright 2011-2026 the original author or authors.
3    *
4    *    This program is free software; you can redistribute it and/or
5    *    modify it under the terms of the GNU General Public License
6    *    as published by the Free Software Foundation; either version 2
7    *    of the License, or (at your option) any later version.
8    *
9    *    You may obtain a copy of the License at
10   *
11   *       https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html
12   *
13   *    This program is distributed in the hope that it will be useful,
14   *    but WITHOUT ANY WARRANTY; without even the implied warranty of
15   *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16   *    GNU General Public License for more details.
17   */
18  package com.hazendaz.maven.makeself;
19  
20  import java.lang.reflect.Field;
21  import java.nio.file.Files;
22  import java.nio.file.Path;
23  import java.util.Arrays;
24  import java.util.List;
25  
26  import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
27  import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
28  import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
29  import org.apache.maven.plugin.MojoFailureException;
30  import org.apache.maven.plugin.logging.Log;
31  import org.eclipse.aether.RepositorySystem;
32  import org.eclipse.aether.RepositorySystemSession;
33  import org.eclipse.aether.artifact.Artifact;
34  import org.eclipse.aether.repository.LocalRepository;
35  import org.eclipse.aether.resolution.ArtifactResolutionException;
36  import org.eclipse.aether.resolution.ArtifactResult;
37  import org.junit.jupiter.api.Assertions;
38  import org.junit.jupiter.api.Assumptions;
39  import org.junit.jupiter.api.Test;
40  import org.junit.jupiter.api.extension.ExtendWith;
41  import org.junit.jupiter.api.io.TempDir;
42  import org.mockito.Mock;
43  import org.mockito.Mockito;
44  import org.mockito.junit.jupiter.MockitoExtension;
45  
46  /**
47   * Tests for {@link AbstractGitMojo}.
48   */
49  @ExtendWith(MockitoExtension.class)
50  class AbstractGitMojoTest {
51  
52      /** Temporary directory for test files. */
53      @TempDir
54      Path tempDir;
55  
56      /** Mock Maven log. */
57      @Mock
58      private Log log;
59  
60      /** Mock repository system session. */
61      @Mock
62      private RepositorySystemSession repoSession;
63  
64      /** Mock repository system. */
65      @Mock
66      private RepositorySystem repositorySystem;
67  
68      /**
69       * Sets a private field on the given object via reflection, walking up the class hierarchy as needed.
70       *
71       * @param obj
72       *            the object
73       * @param fieldName
74       *            the field name
75       * @param value
76       *            the value to set
77       *
78       * @throws Exception
79       *             if the field cannot be found or set
80       */
81      private static void setField(final Object obj, final String fieldName, final Object value) throws Exception {
82          final Field field = findField(obj.getClass(), fieldName);
83          field.setAccessible(true);
84          field.set(obj, value);
85      }
86  
87      /**
88       * Gets a field value from the given object via reflection, walking up the class hierarchy as needed.
89       *
90       * @param obj
91       *            the object
92       * @param fieldName
93       *            the field name
94       *
95       * @return the field value
96       *
97       * @throws Exception
98       *             if the field cannot be found or accessed
99       */
100     @SuppressWarnings("unchecked")
101     private static <T> T getField(final Object obj, final String fieldName) throws Exception {
102         final Field field = findField(obj.getClass(), fieldName);
103         field.setAccessible(true);
104         return (T) field.get(obj);
105     }
106 
107     /**
108      * Finds a declared field by name, walking the class hierarchy.
109      *
110      * @param clazz
111      *            the class to start from
112      * @param name
113      *            the field name
114      *
115      * @return the field
116      *
117      * @throws NoSuchFieldException
118      *             if no field with the given name is found
119      */
120     private static Field findField(final Class<?> clazz, final String name) throws NoSuchFieldException {
121         try {
122             return clazz.getDeclaredField(name);
123         } catch (final NoSuchFieldException e) {
124             if (clazz.getSuperclass() != null) {
125                 return findField(clazz.getSuperclass(), name);
126             }
127             throw e;
128         }
129     }
130 
131     /**
132      * Test runInstaller with a process that succeeds (exit code 0). Verifies log output is written.
133      *
134      * @throws Exception
135      *             the exception
136      */
137     @Test
138     void testRunInstallerSuccess() throws Exception {
139         final GitMojo mojo = new GitMojo();
140         mojo.setLog(log);
141 
142         final List<String> command = AbstractGitMojo.WINDOWS ? Arrays.asList("cmd", "/c", "echo", "hello")
143                 : Arrays.asList("echo", "hello");
144 
145         mojo.runInstaller(command);
146 
147         // runInstaller always logs an empty line after draining the process output
148         Mockito.verify(log).info("");
149     }
150 
151     /**
152      * Test runInstaller with a process that exits with a non-zero status. Verifies the error is logged.
153      *
154      * @throws Exception
155      *             the exception
156      */
157     @Test
158     void testRunInstallerFailedStatus() throws Exception {
159         Assumptions.assumeFalse(AbstractGitMojo.WINDOWS, "Non-zero exit test only applicable on non-Windows");
160 
161         final GitMojo mojo = new GitMojo();
162         mojo.setLog(log);
163 
164         mojo.runInstaller(Arrays.asList("bash", "-c", "exit 1"));
165 
166         Mockito.verify(log).error(Mockito.contains("Process failed with error status:"));
167     }
168 
169     /**
170      * Test extractPortableGit when the expected local directory already exists. Verifies gitPath is set to the existing
171      * location and no artifact resolution is attempted.
172      *
173      * @throws Exception
174      *             the exception
175      */
176     @Test
177     void testExtractPortableGitExistingLocation() throws Exception {
178         final GitMojo mojo = new GitMojo();
179         mojo.setLog(log);
180 
181         final PortableGit portableGit = new PortableGit(log);
182         setField(mojo, "portableGit", portableGit);
183         setField(mojo, "remoteRepositories", List.of());
184 
185         // Create the directory that extractPortableGit looks for
186         final Path existingLocation = tempDir.resolve(portableGit.getName()).resolve(portableGit.getVersion());
187         Files.createDirectories(existingLocation);
188 
189         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
190         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
191         setField(mojo, "repoSession", repoSession);
192 
193         mojo.extractPortableGit();
194 
195         // gitPath should now point into the existing PortableGit location
196         final String gitPath = getField(mojo, "gitPath");
197         Assertions.assertTrue(gitPath.contains(portableGit.getName()), "gitPath should reference the PortableGit name");
198     }
199 
200     /**
201      * Test extractPortableGit when artifact resolution throws ArtifactResolutionException. Verifies
202      * MojoFailureException is thrown.
203      *
204      * @throws Exception
205      *             the exception
206      */
207     @Test
208     void testExtractPortableGitResolutionException() throws Exception {
209         final GitMojo mojo = new GitMojo();
210         mojo.setLog(log);
211 
212         final PortableGit portableGit = new PortableGit(log);
213         setField(mojo, "portableGit", portableGit);
214         setField(mojo, "remoteRepositories", List.of());
215 
216         // Point to tempDir so the "already exists" branch is NOT taken (directory does not exist)
217         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
218         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
219         setField(mojo, "repoSession", repoSession);
220 
221         Mockito.when(repositorySystem.resolveArtifact(Mockito.any(), Mockito.any()))
222                 .thenThrow(new ArtifactResolutionException(List.of(), "resolution failed in test"));
223         setField(mojo, "repositorySystem", repositorySystem);
224 
225         Assertions.assertThrows(MojoFailureException.class, mojo::extractPortableGit);
226     }
227 
228     /**
229      * Test extractPortableGit when the resolved ArtifactResult reports isResolved() == false. Verifies
230      * MojoFailureException is thrown.
231      *
232      * @throws Exception
233      *             the exception
234      */
235     @Test
236     void testExtractPortableGitNotResolved() throws Exception {
237         final GitMojo mojo = new GitMojo();
238         mojo.setLog(log);
239 
240         final PortableGit portableGit = new PortableGit(log);
241         setField(mojo, "portableGit", portableGit);
242         setField(mojo, "remoteRepositories", List.of());
243 
244         // Point to tempDir so the "already exists" branch is NOT taken
245         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
246         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
247         setField(mojo, "repoSession", repoSession);
248 
249         final ArtifactResult notResolved = Mockito.mock(ArtifactResult.class);
250         Mockito.when(notResolved.isResolved()).thenReturn(false);
251         Mockito.when(repositorySystem.resolveArtifact(Mockito.any(), Mockito.any())).thenReturn(notResolved);
252         setField(mojo, "repositorySystem", repositorySystem);
253 
254         Assertions.assertThrows(MojoFailureException.class, mojo::extractPortableGit);
255     }
256 
257     /**
258      * Test checkGitSetup successfully initializes portableGit and resolves to an existing directory. Verifies gitPath
259      * is set after the call.
260      *
261      * @throws Exception
262      *             the exception
263      */
264     @Test
265     void testCheckGitSetupWithExistingLocation() throws Exception {
266         final GitMojo mojo = new GitMojo();
267         mojo.setLog(log);
268         setField(mojo, "remoteRepositories", List.of());
269 
270         // Pre-create the directory so extractPortableGit takes the fast path
271         final PortableGit portableGit = new PortableGit(log);
272         final Path existingLocation = tempDir.resolve(portableGit.getName()).resolve(portableGit.getVersion());
273         Files.createDirectories(existingLocation);
274 
275         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
276         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
277         setField(mojo, "repoSession", repoSession);
278 
279         mojo.checkGitSetup();
280 
281         final String gitPath = getField(mojo, "gitPath");
282         Assertions.assertNotNull(gitPath, "gitPath should be set after checkGitSetup");
283         Assertions.assertFalse(gitPath.isEmpty(), "gitPath should not be empty after checkGitSetup");
284     }
285 
286     /**
287      * Creates a minimal tar.gz archive in the given directory containing a single script file.
288      *
289      * @param tarGzPath
290      *            path where the tar.gz should be written
291      * @param entryName
292      *            the name of the entry inside the archive (may include sub-directories)
293      * @param entryContent
294      *            the bytes to store in the archive entry
295      *
296      * @throws Exception
297      *             if writing fails
298      */
299     private static void createTarGz(final Path tarGzPath, final String entryName, final byte[] entryContent)
300             throws Exception {
301         try (java.io.OutputStream fos = Files.newOutputStream(tarGzPath);
302                 GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(fos);
303                 TarArchiveOutputStream taos = new TarArchiveOutputStream(gzos)) {
304             taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU);
305 
306             // Add a directory entry first
307             final String dirName = entryName.contains("/") ? entryName.substring(0, entryName.lastIndexOf('/') + 1)
308                     : "";
309             if (!dirName.isEmpty()) {
310                 final TarArchiveEntry dirEntry = new TarArchiveEntry(dirName);
311                 taos.putArchiveEntry(dirEntry);
312                 taos.closeArchiveEntry();
313             }
314 
315             // Add the file entry
316             final TarArchiveEntry fileEntry = new TarArchiveEntry(entryName);
317             fileEntry.setSize(entryContent.length);
318             taos.putArchiveEntry(fileEntry);
319             taos.write(entryContent);
320             taos.closeArchiveEntry();
321         }
322     }
323 
324     /**
325      * Test installGit extracts files from a synthetic tar.gz to the configured local repository directory. Verifies
326      * that the content is extracted even though the subsequent "installer" call will fail on non-Windows (the error is
327      * caught and logged rather than re-thrown).
328      *
329      * @throws Exception
330      *             the exception
331      */
332     @Test
333     void testInstallGitExtractsFiles() throws Exception {
334         final GitMojo mojo = new GitMojo();
335         mojo.setLog(log);
336 
337         final PortableGit portableGit = new PortableGit(log);
338         setField(mojo, "portableGit", portableGit);
339 
340         // Stub repoSession so installGit knows where to extract
341         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
342         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
343         setField(mojo, "repoSession", repoSession);
344 
345         // Create a minimal tar.gz that contains one directory entry and one file entry
346         final Path tarGzPath = tempDir.resolve("portable-git.tar.gz");
347         final byte[] scriptContent = "#!/bin/sh\nexit 0\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
348         createTarGz(tarGzPath, portableGit.getName() + "/install.sh", scriptContent);
349 
350         // Create a mock artifact pointing to the synthetic tar.gz
351         final Artifact artifact = Mockito.mock(Artifact.class);
352         Mockito.when(artifact.getFile()).thenReturn(tarGzPath.toFile());
353 
354         final String location = tempDir.toFile().getAbsolutePath() + java.io.File.separator + portableGit.getName()
355                 + java.io.File.separator + portableGit.getVersion();
356 
357         // Should not throw; any IOException from running the "installer" is caught and logged
358         mojo.installGit(artifact, location);
359 
360         // Verify that at least one file was extracted to the PortableGit directory
361         final Path extractedDir = tempDir.resolve(portableGit.getName());
362         Assertions.assertTrue(Files.exists(extractedDir), "PortableGit extraction directory should have been created");
363     }
364 
365     /**
366      * Test installGit gracefully handles an IOException from a bad tar.gz (empty file).
367      *
368      * @throws Exception
369      *             the exception
370      */
371     @Test
372     void testInstallGitHandlesBadTarGz() throws Exception {
373         final GitMojo mojo = new GitMojo();
374         mojo.setLog(log);
375 
376         final PortableGit portableGit = new PortableGit(log);
377         setField(mojo, "portableGit", portableGit);
378         setField(mojo, "repoSession", repoSession);
379 
380         // Create an empty (invalid) file as the "artifact"
381         final Path emptyFile = Files.createTempFile(tempDir, "empty", ".tar.gz");
382 
383         final Artifact artifact = Mockito.mock(Artifact.class);
384         Mockito.when(artifact.getFile()).thenReturn(emptyFile.toFile());
385 
386         final String location = tempDir.toFile().getAbsolutePath() + java.io.File.separator + portableGit.getName()
387                 + java.io.File.separator + portableGit.getVersion();
388 
389         // Should not throw – IOException from bad gzip is caught and logged
390         Assertions.assertDoesNotThrow(() -> mojo.installGit(artifact, location));
391         Mockito.verify(log).error(Mockito.eq(""), Mockito.any(Exception.class));
392     }
393 
394     /**
395      * Test installGit detects a directory-traversal attempt and throws an IOException that is caught internally.
396      * Verifies that the error is logged and no exception escapes.
397      *
398      * @throws Exception
399      *             the exception
400      */
401     @Test
402     void testInstallGitDirectoryTraversal() throws Exception {
403         final GitMojo mojo = new GitMojo();
404         mojo.setLog(log);
405 
406         final PortableGit portableGit = new PortableGit(log);
407         setField(mojo, "portableGit", portableGit);
408 
409         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
410         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
411         setField(mojo, "repoSession", repoSession);
412 
413         // Create a tar.gz with a path-traversal entry (../../evil.sh)
414         final Path tarGzPath = tempDir.resolve("traversal.tar.gz");
415         final byte[] content = "evil content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
416         try (java.io.OutputStream fos = Files.newOutputStream(tarGzPath);
417                 GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(fos);
418                 TarArchiveOutputStream taos = new TarArchiveOutputStream(gzos)) {
419             final TarArchiveEntry fileEntry = new TarArchiveEntry("../../evil.sh");
420             fileEntry.setSize(content.length);
421             taos.putArchiveEntry(fileEntry);
422             taos.write(content);
423             taos.closeArchiveEntry();
424         }
425 
426         final Artifact artifact = Mockito.mock(Artifact.class);
427         Mockito.when(artifact.getFile()).thenReturn(tarGzPath.toFile());
428 
429         final String location = tempDir.toFile().getAbsolutePath() + java.io.File.separator + portableGit.getName()
430                 + java.io.File.separator + portableGit.getVersion();
431 
432         // Should not throw – the traversal IOException is caught and logged
433         Assertions.assertDoesNotThrow(() -> mojo.installGit(artifact, location));
434         Mockito.verify(log, Mockito.atLeastOnce()).error(Mockito.eq(""), Mockito.any(Exception.class));
435     }
436 
437     /**
438      * Test installGit runs the installer and sets gitPath when a valid archive is provided and the installer succeeds.
439      * The runInstaller method is overridden to avoid spawning a real process.
440      *
441      * @throws Exception
442      *             the exception
443      */
444     @Test
445     void testInstallGitRunsInstaller() throws Exception {
446         final GitMojo mojo = new GitMojo() {
447             @Override
448             protected void runInstaller(final java.util.List<String> command)
449                     throws java.io.IOException, InterruptedException {
450                 // Simulate successful installer execution without spawning a process
451             }
452         };
453         mojo.setLog(log);
454 
455         final PortableGit portableGit = new PortableGit(log);
456         setField(mojo, "portableGit", portableGit);
457 
458         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
459         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
460         setField(mojo, "repoSession", repoSession);
461 
462         // Create a minimal tar.gz with a file entry
463         final Path tarGzPath = tempDir.resolve("portable-git-installer.tar.gz");
464         final byte[] scriptContent = "#!/bin/sh\nexit 0\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
465         createTarGz(tarGzPath, portableGit.getName() + "/install.sh", scriptContent);
466 
467         final Artifact artifact = Mockito.mock(Artifact.class);
468         Mockito.when(artifact.getFile()).thenReturn(tarGzPath.toFile());
469 
470         final String location = tempDir.toFile().getAbsolutePath() + java.io.File.separator + portableGit.getName()
471                 + java.io.File.separator + portableGit.getVersion();
472 
473         mojo.installGit(artifact, location);
474 
475         // After successful installation gitPath should be set to location + GIT_USER_BIN
476         final String gitPath = getField(mojo, "gitPath");
477         Assertions.assertTrue(gitPath.contains(portableGit.getName()), "gitPath should reference the PortableGit name");
478     }
479 
480     /**
481      * Test extractPortableGit calls installGit when the artifact is successfully resolved. Verifies that the code path
482      * after isResolved()=true is exercised (lines that call installGit).
483      *
484      * @throws Exception
485      *             the exception
486      */
487     @Test
488     void testExtractPortableGitResolvedAndInstalled() throws Exception {
489         final GitMojo mojo = new GitMojo();
490         mojo.setLog(log);
491 
492         final PortableGit portableGit = new PortableGit(log);
493         setField(mojo, "portableGit", portableGit);
494         setField(mojo, "remoteRepositories", List.of());
495 
496         // Point to tempDir so the "already exists" fast-path is NOT taken
497         final LocalRepository localRepository = new LocalRepository(tempDir.toFile());
498         Mockito.when(repoSession.getLocalRepository()).thenReturn(localRepository);
499         setField(mojo, "repoSession", repoSession);
500 
501         // Create a real tar.gz so installGit can open it
502         final Path tarGzPath = tempDir.resolve("resolved-portable-git.tar.gz");
503         final byte[] scriptContent = "#!/bin/sh\nexit 0\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
504         createTarGz(tarGzPath, portableGit.getName() + "/install.sh", scriptContent);
505 
506         // Mock a fully resolved artifact result
507         final Artifact resolvedArtifact = Mockito.mock(Artifact.class);
508         Mockito.when(resolvedArtifact.getFile()).thenReturn(tarGzPath.toFile());
509 
510         final ArtifactResult resolvedResult = Mockito.mock(ArtifactResult.class);
511         Mockito.when(resolvedResult.isResolved()).thenReturn(true);
512         Mockito.when(resolvedResult.getArtifact()).thenReturn(resolvedArtifact);
513 
514         Mockito.when(repositorySystem.resolveArtifact(Mockito.any(), Mockito.any())).thenReturn(resolvedResult);
515         setField(mojo, "repositorySystem", repositorySystem);
516 
517         // extractPortableGit should call installGit without throwing
518         Assertions.assertDoesNotThrow(mojo::extractPortableGit);
519     }
520 
521 }