From 0673969304bb8de894929b74149d3853773f4324 Mon Sep 17 00:00:00 2001
From: Greg DiCristofaro <gregd@basistech.com>
Date: Tue, 20 Oct 2020 17:18:41 -0400
Subject: [PATCH] means of creating diff

---
 Core/ivy.xml                                  |  26 ++-
 Core/nbproject/project.properties             |   1 +
 Core/nbproject/project.xml                    |   6 +-
 .../integrationtesting/DiffService.java       | 196 ++++++++++++++++++
 .../integrationtesting/MainTestRunner.java    | 148 ++++++++-----
 .../integrationtesting/config/EnvConfig.java  |  29 ++-
 6 files changed, 340 insertions(+), 66 deletions(-)
 create mode 100644 Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/DiffService.java

diff --git a/Core/ivy.xml b/Core/ivy.xml
index a1118e0f61..24b6e0b7e5 100644
--- a/Core/ivy.xml
+++ b/Core/ivy.xml
@@ -3,30 +3,30 @@
     <configurations >
         <!-- module dependencies -->
         <conf name="core"/>
-     
+
     </configurations>
     <dependencies >
         <dependency conf="core->default" org="com.github.vlsi.mxgraph" name="jgraphx" rev="4.1.0" />
-        
+
         <dependency conf="core->default" org="org.apache.activemq" name="activemq-all" rev="5.11.1"/>
         <dependency conf="core->default" org="org.apache.curator" name="curator-client" rev="2.8.0"/>
         <dependency conf="core->default" org="org.apache.curator" name="curator-framework" rev="2.8.0"/>
         <dependency conf="core->default" org="org.apache.curator" name="curator-recipes" rev="2.8.0"/>
-               
+
         <dependency conf="core->default" org="org.python" name="jython-standalone" rev="2.7.0" />
-        
+
         <dependency conf="core->default" org="com.adobe.xmp" name="xmpcore" rev="5.1.2"/>
         <dependency conf="core->default" org="org.apache.zookeeper" name="zookeeper" rev="3.4.6"/>
 
         <dependency conf="core->default" org="com.healthmarketscience.jackcess" name="jackcess" rev="2.2.0"/>
         <dependency conf="core->default" org="com.healthmarketscience.jackcess" name="jackcess-encrypt" rev="2.1.4"/>
-        
+
         <dependency conf="core->default" org="org.apache.commons" name="commons-dbcp2" rev="2.1.1"/>
         <dependency conf="core->default" org="org.apache.commons" name="commons-pool2" rev="2.4.2"/>
         <dependency conf="core->default" org="commons-codec" name="commons-codec" rev="1.11"/>
 
         <dependency conf="core->default" org="org.jsoup" name="jsoup" rev="1.10.3"/>
-		
+
         <dependency conf="core->default" org="com.fasterxml.jackson.core" name="jackson-databind" rev="2.9.7"/>
         <dependency conf="core->default" org="com.drewnoakes" name="metadata-extractor" rev="2.11.0"/>
 
@@ -34,7 +34,7 @@
         <dependency conf="core->default" org="org.apache.opennlp" name="opennlp-tools" rev="1.9.1"/>
 
         <dependency conf="core->default" org="com.ethteck.decodetect" name="decodetect-core" rev="0.3"/>
-        
+
         <dependency conf="core->default" org="org.sejda.webp-imageio" name="webp-imageio-sejda" rev="0.1.0"/>
         <dependency conf="core->default" org="com.googlecode.libphonenumber" name="libphonenumber" rev="3.5" />
         <dependency conf="core->default" org="commons-validator" name="commons-validator" rev="1.6"/>
@@ -45,13 +45,17 @@
 
         <!-- for yaml reading/writing -->
         <dependency org="org.yaml" name="snakeyaml" rev="1.27"/>
-        
+
         <!-- map support for geolocation -->
-        <dependency conf="core->default" org="org.jxmapviewer" name="jxmapviewer2" rev="2.4"/> 
-        
+        <dependency conf="core->default" org="org.jxmapviewer" name="jxmapviewer2" rev="2.4"/>
+
         <!-- For Discovery testing -->
         <dependency conf="core->default" org="org.mockito" name="mockito-core" rev="3.5.7"/>
-        
+
+        <!-- for handling diffs -->
+        <dependency org="io.github.java-diff-utils" name="java-diff-utils" rev="4.8"/>
+
+
         <!-- https://mvnrepository.com/artifact/javax.ws.rs/javax.ws.rs-api -->
         <dependency conf="core->default" org="javax.ws.rs" name="javax.ws.rs-api" rev="2.0"/>
         <override org="jakarta.ws.rs" module="jakarta.ws.rs-api" rev="2.1.5"/>
diff --git a/Core/nbproject/project.properties b/Core/nbproject/project.properties
index def49783e5..f15316a7bd 100644
--- a/Core/nbproject/project.properties
+++ b/Core/nbproject/project.properties
@@ -21,6 +21,7 @@ file.reference.commons-dbcp2-2.1.1.jar=release\\modules\\ext\\commons-dbcp2-2.1.
 file.reference.commons-digester-1.8.1.jar=release\\modules\\ext\\commons-digester-1.8.1.jar
 file.reference.commons-logging-1.2.jar=release\\modules\\ext\\commons-logging-1.2.jar
 file.reference.commons-pool2-2.4.2.jar=release\\modules\\ext\\commons-pool2-2.4.2.jar
+file.reference.java-diff-utils-4.8.jar=release\\modules\\ext\\java-diff-utils-4.8.jar
 file.reference.commons-validator-1.6.jar=release\\modules\\ext\\commons-validator-1.6.jar
 file.reference.curator-client-2.8.0.jar=release\\modules\\ext\\curator-client-2.8.0.jar
 file.reference.curator-framework-2.8.0.jar=release\\modules\\ext\\curator-framework-2.8.0.jar
diff --git a/Core/nbproject/project.xml b/Core/nbproject/project.xml
index b7471c6a9e..597af6c073 100644
--- a/Core/nbproject/project.xml
+++ b/Core/nbproject/project.xml
@@ -643,6 +643,10 @@
                 <runtime-relative-path>ext/commons-collections-3.2.2.jar</runtime-relative-path>
                 <binary-origin>release\modules\ext\commons-collections-3.2.2.jar</binary-origin>
             </class-path-extension>
+            <class-path-extension>
+                <runtime-relative-path>ext/java-diff-utils-4.8.jar</runtime-relative-path>
+                <binary-origin>release\modules\ext\java-diff-utils-4.8.jar</binary-origin>
+            </class-path-extension>
             <class-path-extension>
                 <runtime-relative-path>ext/SparseBitSet-1.1.jar</runtime-relative-path>
                 <binary-origin>release\modules\ext\SparseBitSet-1.1.jar</binary-origin>
@@ -811,7 +815,7 @@
                 <runtime-relative-path>ext/grpc-netty-shaded-1.19.0.jar</runtime-relative-path>
                 <binary-origin>release\modules\ext\grpc-netty-shaded-1.19.0.jar</binary-origin>
             </class-path-extension>
-			<class-path-extension>
+            <class-path-extension>
                 <runtime-relative-path>ext/snakeyaml-1.27.jar</runtime-relative-path>
                 <binary-origin>release\modules\ext\snakeyaml-1.27.jar</binary-origin>
             </class-path-extension>
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/DiffService.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/DiffService.java
new file mode 100644
index 0000000000..80e72335b5
--- /dev/null
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/DiffService.java
@@ -0,0 +1,196 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2020 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.integrationtesting;
+
+import com.github.difflib.DiffUtils;
+import com.github.difflib.patch.AbstractDelta;
+import com.github.difflib.patch.Chunk;
+import com.github.difflib.patch.Patch;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.commons.lang3.tuple.Pair;
+
+/**
+ * Handles creating diffs with files.
+ */
+public class DiffService {
+
+    private static final Logger logger = Logger.getLogger(DiffUtils.class.getName());
+    private static final String ORIG_LINE_PREFIX = "< ";
+    private static final String CUR_LINE_PREFIX = "> ";
+    private static final String[] DIFF_BREAK = new String[]{"", "", ""};
+    private static final String[] FILE_DIFF_BREAK = new String[]{"", "", "", ""};
+    private static final String NEW_LINE = System.getProperty("line.separator");
+
+    /**
+     * Creates a diff of all the files found in the directories provided or
+     * between two files.
+     *
+     * @param prevResult The previous file or directory. Must be of same type as
+     * curResult (file/directory).
+     * @param curResult The current file or directory. Must be of same type as
+     * prevResult (file/directory).
+     * @return The string contents of the diff.
+     */
+    String diffFilesOrDirs(File prevResult, File curResult) {
+        if (prevResult.isDirectory() && curResult.isDirectory()) {
+            final Map<String, File> prevFiles = FileUtils.listFiles(prevResult, null, true).stream()
+                    .collect(Collectors.toMap(f -> getRelative(prevResult, f), f -> f, (f1, f2) -> f1));
+
+            final Map<String, File> curFiles = FileUtils.listFiles(curResult, null, true).stream()
+                    .collect(Collectors.toMap(f -> getRelative(curResult, f), f -> f, (f1, f2) -> f1));
+
+            Map<String, Pair<File, File>> prevCurMapping = Stream.of(prevFiles, curFiles)
+                    .flatMap((map) -> map.keySet().stream())
+                    .collect(Collectors.toMap(k -> k, k -> Pair.of(prevFiles.get(k), curFiles.get(k)), (v1, v2) -> v1));
+
+            String fullDiff = prevCurMapping.entrySet().stream()
+                    .map((entry) -> getFileDiffs(entry.getValue().getLeft(), entry.getValue().getRight(), entry.getKey()))
+                    .filter((val) -> val != null)
+                    .collect(Collectors.joining(String.join(NEW_LINE, FILE_DIFF_BREAK)));
+
+            return fullDiff;
+
+        } else if (prevResult.isFile() && curResult.isFile()) {
+            return getFileDiffs(prevResult, curResult, prevResult.toString() + " / " + curResult.toString());
+
+        } else {
+            logger.log(Level.WARNING, String.format("%s and %s must be of same type (directory/file).", prevResult.toString(), curResult.toString()));
+            return null;
+        }
+    }
+
+    /**
+     * Handles creating a diff between files noting if one of them is not
+     * present. If both are not present or both are the same, null is returned.
+     *
+     * @param orig The original file.
+     * @param cur The current file.
+     * @param identifier The identifier for the header.
+     * @return The String representing the differences.
+     */
+    private String getFileDiffs(File orig, File cur, String identifier) {
+        boolean hasOrig = (orig != null && orig.exists());
+        boolean hasCur = (cur != null && cur.exists());
+        if (!hasOrig && !hasCur) {
+            return null;
+        } else if (!hasOrig && hasCur) {
+            return getHeaderWithDivider("MISSING FILE IN CURRENT: " + identifier);
+        } else if (hasOrig && !hasCur) {
+            return getHeaderWithDivider("ADDITIONAL FILE IN CURRENT: " + identifier);
+        } else {
+            try {
+                return diffLines(Files.readAllLines(orig.toPath()), Files.readAllLines(cur.toPath()), getHeaderWithDivider(identifier + ":"));
+            } catch (IOException ex) {
+                return getHeaderWithDivider(String.format("ERROR reading files at %s / %s %s%s",
+                        orig.toString(), cur.toString(), NEW_LINE, ExceptionUtils.getStackTrace(ex)));
+            }
+        }
+    }
+
+    private String getChunkLineNumString(Chunk<?> chunk) {
+        return String.format("%d,%d", chunk.getPosition() + 1, chunk.getLines().size());
+    }
+
+    /**
+     * Gets a github-like line difference (i.e. -88,3 +90,3) of the form
+     * -orig_line_num,orig_lines, +new_line_num,new_lines.
+     *
+     * @param orig The previous chunk.
+     * @param cur The current chunk.
+     * @return The line number difference.
+     */
+    private String getDiffLineNumString(Chunk<?> orig, Chunk<?> cur) {
+        return String.format("-%s +%s", getChunkLineNumString(orig), getChunkLineNumString(cur));
+    }
+
+    /**
+     * Creates a line by line difference similar to integration tests like:
+     * < original
+     * > new
+     *
+     * @param orig The original chunk.
+     * @param cur The new chunk.
+     * @return The lines representing the diff.
+     */
+    private List<String> getLinesDiff(Chunk<String> orig, Chunk<String> cur) {
+        Stream<String> origPrefixed = orig.getLines().stream()
+                .map((line) -> ORIG_LINE_PREFIX + line);
+
+        Stream<String> curPrefixed = cur.getLines().stream()
+                .map((line) -> CUR_LINE_PREFIX + line);
+
+        return Stream.concat(origPrefixed, curPrefixed)
+                .collect(Collectors.toList());
+    }
+
+    private String getLinesDiffString(AbstractDelta<String> delta) {
+        String lineNums = getDiffLineNumString(delta.getSource(), delta.getTarget());
+        List<String> linesDiff = getLinesDiff(delta.getSource(), delta.getTarget());
+
+        return Stream.concat(Stream.of(lineNums), linesDiff.stream())
+                .collect(Collectors.joining(NEW_LINE)) + NEW_LINE;
+    }
+
+    /**
+     * Creates a line difference String with a header if non-null. Null is
+     * returned if there is no diff.
+     *
+     * @param orig The original lines.
+     * @param cur The current lines.
+     * @param header The header to be used if non-null diff. If header is null,
+     * no header included.
+     * @return The pretty-printed diff.
+     */
+    private String diffLines(List<String> orig, List<String> cur, String header) {
+        //compute the patch: this is the diffutils part
+        Patch<String> patch = DiffUtils.diff(orig, cur);
+
+        String diff = patch.getDeltas().stream()
+                .map(delta -> getLinesDiffString(delta))
+                .collect(Collectors.joining(String.join(NEW_LINE, DIFF_BREAK)));
+
+        if (StringUtils.isBlank(diff)) {
+            return null;
+        }
+
+        return (header != null)
+                ? header + NEW_LINE + diff
+                : diff;
+    }
+
+    private String getHeaderWithDivider(String remark) {
+        String divider = "-----------------------------------------------------------";
+        return String.join(NEW_LINE, divider, remark, divider);
+    }
+
+    private String getRelative(File rootDirectory, File file) {
+        return rootDirectory.toURI().relativize(file.toURI()).getPath();
+    }
+}
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/MainTestRunner.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/MainTestRunner.java
index 13d351c1b6..fbba921197 100644
--- a/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/MainTestRunner.java
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/MainTestRunner.java
@@ -35,6 +35,8 @@
 import java.util.stream.Stream;
 import junit.framework.Test;
 import junit.framework.TestCase;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang.StringUtils;
 import org.apache.cxf.common.util.CollectionUtils;
 import org.netbeans.junit.NbModuleSuite;
 import org.openide.util.Lookup;
@@ -65,6 +67,7 @@ public class MainTestRunner extends TestCase {
     private static final Logger logger = Logger.getLogger(MainTestRunner.class.getName());
     private static final String CONFIG_FILE_KEY = "integrationConfigFile";
     private static final ConfigDeserializer configDeserializer = new ConfigDeserializer();
+    private static final DiffService diffService = new DiffService();
     private static final ConfigurationModuleManager configurationModuleManager = new ConfigurationModuleManager();
 
     /**
@@ -107,63 +110,67 @@ public void runIntegrationTests() {
 
         if (!CollectionUtils.isEmpty(config.getTestSuites())) {
             for (TestSuiteConfig testSuiteConfig : config.getTestSuites()) {
-                String caseName = testSuiteConfig.getName();
-
                 for (CaseType caseType : IntegrationCaseType.getCaseTypes(testSuiteConfig.getCaseTypes())) {
-                    // create an autopsy case for each case in the config and for each case type for the specified case.
-                    // then run ingest for the case.
-                    Case autopsyCase = createCaseWithDataSources(
-                            envConfig.getWorkingDirectory(),
-                            envConfig.getRootCaseOutputPath(),
-                            caseName,
-                            caseType,
-                            testSuiteConfig.getDataSources());
-
-                    if (autopsyCase == null || autopsyCase != Case.getCurrentCase()) {
-                        logger.log(Level.WARNING,
-                                String.format("Case was not properly ingested or setup correctly for environment.  Case is %s and current case is %s.",
-                                        autopsyCase, Case.getCurrentCase()));
-                        return;
-                    }
-
-                    // run configuration modules and get result
-                    Pair<IngestJobSettings, List<ConfigurationModule<?>>> configurationResult
-                            = configurationModuleManager.runConfigurationModules(caseName, testSuiteConfig.getConfigurationModules());
-
-                    IngestJobSettings ingestSettings = configurationResult.first();
-                    List<ConfigurationModule<?>> configModules = configurationResult.second();
-
-                    // run ingest with ingest settings derived from configuration modules.
-                    runIngest(autopsyCase, ingestSettings, caseName);
-
-                    // once ingested, run integration tests to produce output.
-                    OutputResults results = runIntegrationTests(testSuiteConfig.getIntegrationTests());
-
-                    // revert any autopsy environment changes made by configuration modules.
-                    configurationModuleManager.revertConfigurationModules(configModules);
-
-                    String outputFolder = PathUtil.getAbsolutePath(envConfig.getWorkingDirectory(), envConfig.getRootTestOutputPath());
-
-                    // write the results for the case to a file
-                    results.serializeToFile(
-                            envConfig.getUseRelativeOutput() == true ? 
-                                    Paths.get(outputFolder, testSuiteConfig.getRelativeOutputPath()).toString() : 
-                                    outputFolder,
-                            testSuiteConfig.getName(),
-                            caseType
-                    );
-
                     try {
-                        Case.closeCurrentCase();
-                    } catch (CaseActionException ex) {
-                        logger.log(Level.WARNING, "There was an error while trying to close current case: {0}", caseName);
-                        return;
+                        runIntegrationTestSuite(envConfig, caseType, testSuiteConfig);
+                    } catch (CaseActionException | IllegalStateException ex) {
+                        logger.log(Level.WARNING, "There was an error working with current case: " + testSuiteConfig.getName(), ex);
                     }
                 }
             }
+
+            // write diff to file if requested
+            writeDiff(envConfig);
         }
     }
 
+    /**
+     * Runs a single test suite.
+     *
+     * @param envConfig The integrationt test environment config.
+     * @param caseType The type of case (single user, multi user).
+     * @param testSuiteConfig The configuration for the case.
+     */
+    private void runIntegrationTestSuite(EnvConfig envConfig, CaseType caseType, TestSuiteConfig testSuiteConfig) throws CaseActionException, IllegalStateException {
+
+        String caseName = testSuiteConfig.getName();
+
+        // create an autopsy case for each case in the config and for each case type for the specified case.
+        // then run ingest for the case.
+        Case autopsyCase = createCaseWithDataSources(
+                envConfig.getWorkingDirectory(),
+                envConfig.getRootCaseOutputPath(),
+                caseName,
+                caseType,
+                testSuiteConfig.getDataSources());
+        if (autopsyCase == null || autopsyCase != Case.getCurrentCase()) {
+            throw new IllegalStateException(String.format("Case was not properly ingested or setup correctly for environment.  Case is %s and current case is %s.",
+                    autopsyCase, Case.getCurrentCase()));
+        }
+        // run configuration modules and get result
+        Pair<IngestJobSettings, List<ConfigurationModule<?>>> configurationResult
+                = configurationModuleManager.runConfigurationModules(caseName, testSuiteConfig.getConfigurationModules());
+        IngestJobSettings ingestSettings = configurationResult.first();
+        List<ConfigurationModule<?>> configModules = configurationResult.second();
+        // run ingest with ingest settings derived from configuration modules.
+        runIngest(autopsyCase, ingestSettings, caseName);
+        // once ingested, run integration tests to produce output.
+        OutputResults results = runIntegrationTests(testSuiteConfig.getIntegrationTests());
+        // revert any autopsy environment changes made by configuration modules.
+        configurationModuleManager.revertConfigurationModules(configModules);
+        String outputFolder = PathUtil.getAbsolutePath(envConfig.getWorkingDirectory(), envConfig.getRootTestOutputPath());
+        // write the results for the case to a file
+        results.serializeToFile(
+                envConfig.getUseRelativeOutput() == true
+                ? Paths.get(outputFolder, testSuiteConfig.getRelativeOutputPath()).toString()
+                : outputFolder,
+                testSuiteConfig.getName(),
+                caseType
+        );
+
+        Case.closeCurrentCase();
+    }
+
     /**
      * Creates a case with the given data sources.
      *
@@ -241,6 +248,7 @@ private void addDataSourcesToCase(List<String> pathStrings, String caseName) {
 
     /**
      * Runs ingest on the current case.
+     *
      * @param openCase The currently open case.
      * @param ingestJobSettings The ingest job settings to be used.
      * @param caseName The name of the case to be used for error messages.
@@ -291,7 +299,7 @@ private OutputResults runIntegrationTests(TestingConfig testSuiteConfig) {
                 if (testMethod.getParameters().length > 1) {
                     throw new IllegalArgumentException(String.format("Could not call method %s in class %s.  Multiple parameters cannot be handled.",
                             testMethod.getName(), testGroup.getClass().getCanonicalName()));
-                // if there is a parameter, deserialize parameters to the specified type.
+                    // if there is a parameter, deserialize parameters to the specified type.
                 } else if (testMethod.getParameters().length > 0) {
                     parameters = new Object[]{configDeserializer.convertToObj(parametersMap, testMethod.getParameterTypes()[0])};
                 }
@@ -312,12 +320,13 @@ private OutputResults runIntegrationTests(TestingConfig testSuiteConfig) {
         return results;
     }
 
-
     /**
      * Runs a test method in a test suite on the current case.
+     *
      * @param testGroup The test suite to which the method belongs.
      * @param testMethod The java reflection method to run.
-     * @param parameters The parameters to use with this method or none/empty array.
+     * @param parameters The parameters to use with this method or none/empty
+     * array.
      * @return The results of running the method.
      */
     private Object runIntegrationTestMethod(IntegrationTestGroup testGroup, Method testMethod, Object[] parameters) {
@@ -343,4 +352,39 @@ private Object runIntegrationTestMethod(IntegrationTestGroup testGroup, Method t
 
         return serializableResult;
     }
+
+    /**
+     * Writes any differences found between gold and output to a diff file. Only
+     * works if a gold and diff location are specified in the EnvConfig.
+     *
+     * @param envConfig The env config.
+     */
+    private void writeDiff(EnvConfig envConfig) {
+        if (StringUtils.isBlank(envConfig.getRootGoldPath()) || StringUtils.isBlank(envConfig.getDiffOutputPath())) {
+            logger.log(Level.INFO, "gold path or diff output path not specified.  Not creating diff.");
+        }
+
+        String goldPath = PathUtil.getAbsolutePath(envConfig.getWorkingDirectory(), envConfig.getRootGoldPath());
+        File goldDir = new File(goldPath);
+        if (!goldDir.exists()) {
+            logger.log(Level.WARNING, String.format("Gold does not exist at location: %s.  Not creating diff.", goldDir.toString()));
+        }
+
+        String outputPath = PathUtil.getAbsolutePath(envConfig.getWorkingDirectory(), envConfig.getRootCaseOutputPath());
+        File outputDir = new File(outputPath);
+        if (!outputDir.exists()) {
+            logger.log(Level.WARNING, String.format("Output path does not exist at location: %s.  Not creating diff.", outputDir.toString()));
+        }
+
+        String diffPath = PathUtil.getAbsolutePath(envConfig.getWorkingDirectory(), envConfig.getDiffOutputPath());
+        String diff = diffService.diffFilesOrDirs(goldDir, outputDir);
+        if (StringUtils.isNotBlank(diff)) {
+            try {
+                FileUtils.writeStringToFile(new File(diffPath), diff, "UTF-8");
+            } catch (IOException ex) {
+                logger.log(Level.SEVERE, "Unable to write diff file to " + diffPath);
+            }
+        }
+
+    }
 }
diff --git a/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/config/EnvConfig.java b/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/config/EnvConfig.java
index ccfefd6ba5..0c725d2f28 100644
--- a/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/config/EnvConfig.java
+++ b/Core/test/qa-functional/src/org/sleuthkit/autopsy/integrationtesting/config/EnvConfig.java
@@ -29,12 +29,15 @@ public class EnvConfig {
     private final String rootCaseOutputPath;
     private final String rootTestOutputPath;
     private final String rootTestSuitesPath;
-
+    private final String rootGoldPath;
+    private final String diffOutputPath;
+    
     private final ConnectionConfig connectionInfo;
 
     private String workingDirectory;
     private Boolean useRelativeOutput;
 
+
     /**
      * Main constructor.
      *
@@ -50,6 +53,8 @@ public class EnvConfig {
      * the same relative path structure as the file (i.e. if file was found at
      * /rootTestSuitesPath/folderX/fileY.json then it will now be outputted in
      * /rootTestOutputPath/folderX/fileY/)
+     * @param rootGoldPath The path to the gold data for diff comparison.
+     * @param diffOutputPath The file location for diff output.
      */
     @JsonCreator
     public EnvConfig(
@@ -58,11 +63,15 @@ public EnvConfig(
             @JsonProperty("rootTestOutputPath") String rootTestOutputPath,
             @JsonProperty("connectionInfo") ConnectionConfig connectionInfo,
             @JsonProperty("workingDirectory") String workingDirectory,
-            @JsonProperty("useRelativeOutput") Boolean useRelativeOutput) {
+            @JsonProperty("useRelativeOutput") Boolean useRelativeOutput,
+            @JsonProperty("rootGoldPath") String rootGoldPath,
+            @JsonProperty("diffOutputPath") String diffOutputPath) {
 
         this.rootCaseOutputPath = rootCaseOutputPath;
         this.rootTestOutputPath = rootTestOutputPath;
         this.rootTestSuitesPath = rootTestSuitesPath;
+        this.rootGoldPath = rootGoldPath;
+        this.diffOutputPath = diffOutputPath;
         this.connectionInfo = connectionInfo;
 
         this.workingDirectory = workingDirectory;
@@ -139,4 +148,20 @@ public boolean getUseRelativeOutput() {
     public void setUseRelativeOutput(boolean useRelativeOutput) {
         this.useRelativeOutput = useRelativeOutput;
     }
+
+    /**
+     * @return The path to the gold data for diff comparison.
+     */
+    public String getRootGoldPath() {
+        return rootGoldPath;
+    }
+
+    /**
+     * @return The file location for diff output.
+     */
+    public String getDiffOutputPath() {
+        return diffOutputPath;
+    }
+    
+    
 }
-- 
GitLab