diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ee3ca4bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +**/.gradle +**/build +libs +fips201-card-conformance-tool-* +cct-results-*.zip +cct-docker-data +*.log +.DS_Store diff --git a/.gitignore b/.gitignore index e1ec343b..dc6e20b7 100644 --- a/.gitignore +++ b/.gitignore @@ -116,4 +116,6 @@ build # Build artifacts fips201-card-conformance-tool-[0-9]*\.[0-9]*\.[0-9]*-beta-[0-9]*\.zip fips201-card-conformance-tool-[0-9]*\.[0-9]*\.[0-9]*-beta-[0-9]*/* +/cct-results-*.zip +/cct-docker-data/ libs/* diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 00000000..fd0c2537 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,25 @@ +# Docker + +Build the image from the repository root: + +```sh +docker build -t piv-conformance . +``` + +The image build compiles all modules and runs the focused GUI packaging tests. Running the Swing application with a smart-card reader is supported only on a native Linux Docker host because Docker Desktop does not directly expose host USB or PC/SC devices. + +On Linux, expose the X11 display, the host PC/SC socket, and a persistent data directory: + +```sh +mkdir -p cct-docker-data +docker run --rm \ + -e DISPLAY \ + -e XAUTHORITY=/tmp/.Xauthority \ + -v "${XAUTHORITY:-$HOME/.Xauthority}:/tmp/.Xauthority:ro" \ + -v /tmp/.X11-unix:/tmp/.X11-unix:ro \ + -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm \ + -v "$PWD/cct-docker-data:/data" \ + piv-conformance +``` + +The container seeds `/data` with the required databases and configuration on first launch. Logs, artifacts, and generated result ZIPs remain in the mounted `cct-docker-data` directory. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c0c9f944 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM eclipse-temurin:11-jdk-jammy AS build + +ARG CCT_GIT_COMMIT=unknown +ARG CCT_GIT_COMMIT_EPOCH=0 + +WORKDIR /workspace +COPY . . + +RUN cd cardlib \ + && ./gradlew --no-daemon clean install \ + -x test -x junitPlatformTest -x generateHtmlTestReports +RUN cd conformancelib \ + && ./gradlew --no-daemon clean install -x test -x junitPlatformTest +RUN cd tools/85b-swing-gui \ + && ./gradlew --no-daemon clean test shadowJar + +RUN mkdir -p /opt/cct/bootstrap \ + && cp tools/85b-swing-gui/build/libs/*-shadow.jar /opt/cct/cct.jar \ + && cp cardlib/src/main/resources/user_log_config.xml /opt/cct/bootstrap/ \ + && cp conformancelib/src/main/resources/pdval.properties /opt/cct/bootstrap/ \ + && cp -R conformancelib/src/main/resources/x509-certs /opt/cct/bootstrap/ \ + && cp conformancelib/testdata/PIV*Cards.db /opt/cct/bootstrap/ \ + && cp tools/85b-swing-gui/src/main/resources/build.version /opt/cct/bootstrap/ + +FROM eclipse-temurin:11-jre-jammy AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fontconfig libpcsclite1 libxext6 libxi6 libxrender1 libxtst6 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /opt/cct /opt/cct +COPY docker/entrypoint.sh /usr/local/bin/cct-entrypoint +RUN chmod 0755 /usr/local/bin/cct-entrypoint \ + && mkdir -p /data + +VOLUME ["/data"] +WORKDIR /data +ENTRYPOINT ["cct-entrypoint"] diff --git a/cardlib/build.gradle b/cardlib/build.gradle index 0d3f7958..a4300c62 100644 --- a/cardlib/build.gradle +++ b/cardlib/build.gradle @@ -12,7 +12,6 @@ buildscript { plugins { id 'java-library' - id "org.ajoberstar.grgit" version "4.1.0" id 'com.github.johnrengelman.shadow' version '4.0.4' id 'de.jjohannes.extra-java-module-info' version '0.1' } @@ -161,13 +160,11 @@ junitPlatformTest { println 'Cloning, please wait...' File cardsDir = new File(project.getRootDir(), 'build/classes/java/test/gov/gsa/pivconformance/cardlib/test/gsa-icam-card-builder') if (!cardsDir.exists()) { - def grgit = org.ajoberstar.grgit.Grgit.clone(dir: cardsDir, uri: 'https://github.com/GSA/gsa-icam-card-builder', checkout: true, refToCheckout: 'master') - if (grgit != null) { - grgit.describe() - System.out.println 'Cloned ' + cardsDir.getName() - } else { - System.out.println 'Couldn\'t clone gsa-icam-card-builder' + project.exec { + commandLine 'git', 'clone', '--branch', 'master', '--single-branch', + 'https://github.com/GSA/gsa-icam-card-builder', cardsDir } + System.out.println 'Cloned ' + cardsDir.getName() } else { System.out.println cardsDir.getName() + ' exists, reusing' } @@ -210,12 +207,12 @@ compileJava { doFirst { version = getVersion() - def dir = "${project.rootDir}/../" - def git = org.ajoberstar.grgit.Grgit.open(dir: dir) - def commit = git.head() - - def commitId = commit.abbreviatedId - def commitDate = commit.getDate() + def repositoryDir = project.rootDir.parentFile + def commitId = System.getenv('CCT_GIT_COMMIT') ?: + ['git', 'rev-parse', '--short', 'HEAD'].execute(null, repositoryDir).text.trim() + def commitDateText = System.getenv('CCT_GIT_COMMIT_EPOCH') ?: + ['git', 'show', '-s', '--format=%ct', 'HEAD'].execute(null, repositoryDir).text.trim() + def commitDate = new Date(Long.parseLong(commitDateText) * 1000L) def buildDate = new Date() File resourcesDir = new File(project.getProjectDir(), 'src/main/resources/') File propertiesFile = new File(resourcesDir, 'version.properties') diff --git a/conformancelib/build.gradle b/conformancelib/build.gradle index 5bf1f8fe..36fba312 100644 --- a/conformancelib/build.gradle +++ b/conformancelib/build.gradle @@ -15,7 +15,6 @@ buildscript { plugins { id 'java-library' - id 'org.ajoberstar.grgit' version '2.1.0' id 'com.github.johnrengelman.shadow' version '4.0.4' id "de.jjohannes.extra-java-module-info" version "0.1" } @@ -302,4 +301,3 @@ task install(type: Copy) { from jar into '../libs' } - diff --git a/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/configuration/ConformanceTestDatabase.java b/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/configuration/ConformanceTestDatabase.java index 053e1578..3f7872b3 100644 --- a/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/configuration/ConformanceTestDatabase.java +++ b/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/configuration/ConformanceTestDatabase.java @@ -5,12 +5,12 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; +import java.nio.file.Path; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; -import java.sql.SQLClientInfoException; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; @@ -23,6 +23,7 @@ public class ConformanceTestDatabase { private static final Logger s_logger = LoggerFactory.getLogger(ConformanceTestDatabase.class); private static final String TEST_SET = "SELECT * FROM TestCases WHERE Enabled = 1"; + private Path m_databasePath; public ConformanceTestDatabase(Connection conn) { setConnconnection(conn); @@ -32,8 +33,13 @@ public Connection getConnection() { return m_conn; } + public Path getDatabasePath() { + return m_databasePath; + } + public void setConnconnection(Connection conn) { m_conn = conn; + m_databasePath = null; } public int getTestCaseCount() { @@ -59,11 +65,14 @@ public void openDatabaseInFile(String filename) throws ConfigurationException { throw new ConfigurationException("Database file " + filename + " does not exist"); } - String dbUrl = null; - try { - Class.forName("org.sqlite.JDBC"); - dbUrl = "jdbc:sqlite:" + f.getCanonicalPath(); - } catch (IOException | ClassNotFoundException e) { + String dbUrl = null; + Path databasePath = null; + try { + Class.forName("org.sqlite.JDBC"); + File canonicalFile = f.getCanonicalFile(); + dbUrl = "jdbc:sqlite:" + canonicalFile.getPath(); + databasePath = canonicalFile.toPath().toAbsolutePath().normalize(); + } catch (IOException | ClassNotFoundException e) { s_logger.error("Unable to calculate canonical name for database file", e); throw new ConfigurationException("Unable to calculate canonical name for database file", e); } @@ -82,14 +91,10 @@ public void openDatabaseInFile(String filename) throws ConfigurationException { } catch (SQLException e) { s_logger.error("Unable to read driver metadata", e); } - } - m_conn = conn; - try { - m_conn.setClientInfo("filename", filename); - } catch (SQLClientInfoException e) { - s_logger.error("setClientInfo failed for database connection.", e); } - s_logger.info("Opened conformance test database in {}", filename); + m_conn = conn; + m_databasePath = databasePath; + s_logger.info("Opened conformance test database in {}", filename); } public List getTestCases() throws ConfigurationException { diff --git a/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/utilities/TestRunLogController.java b/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/utilities/TestRunLogController.java index 8f248312..54c5b61a 100644 --- a/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/utilities/TestRunLogController.java +++ b/conformancelib/src/main/java/gov/gsa/pivconformance/conformancelib/utilities/TestRunLogController.java @@ -19,10 +19,13 @@ import java.io.File; import java.io.IOException; import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; +import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.security.ProtectionDomain; import java.util.Arrays; import java.util.Calendar; @@ -42,6 +45,7 @@ public class TestRunLogController { private static final org.slf4j.Logger s_logger = LoggerFactory.getLogger(TestRunLogController.class); private static final TestRunLogController INSTANCE = new TestRunLogController(); + private static final String CONFORMANCE_HEADER = "Date,Test Id,Description,Expected Result,Actual Result"; /* * Note that these names MUST match the user_log_config.xml appender names. @@ -369,10 +373,33 @@ public void setStartTimes() { Iterator i = m_appenders.entrySet().iterator(); while (i.hasNext()) { me = (Map.Entry>) i.next(); + String logName = me.getKey(); TimeStampedFileAppender appender = me.getValue(); + try { + appender.stop(); + String filename = m_filenames.get(logName); + resetLogFile(Paths.get(filename), "CONFORMANCELOG".equals(logName)); + appender.setFile(filename); + appender.setAppend(true); + appender.start(); + appender.setAppend(false); + } catch (IOException e) { + s_logger.error("Unable to prepare {} for a new test run", logName, e); + throw new IllegalStateException("Unable to prepare logs for a new test run", e); + } appender.setStartTime(startTime); } } + + static void resetLogFile(Path file, boolean conformanceCsv) throws IOException { + Path absolute = file.toAbsolutePath().normalize(); + Path parent = absolute.getParent(); + if (parent != null) Files.createDirectories(parent); + byte[] contents = conformanceCsv + ? (CONFORMANCE_HEADER + System.lineSeparator()).getBytes(StandardCharsets.UTF_8) + : new byte[0]; + Files.write(absolute, contents, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } @SuppressWarnings("unchecked") /** @@ -474,30 +501,9 @@ private boolean rollFile(String oldPath, String newPath) { * */ - @SuppressWarnings("unchecked") public void cleanup() { - Map.Entry me = null; - Iterator i = m_loggers.entrySet().iterator(); ArtifactWriter.prependNames(m_timeStamp); ArtifactWriter.clean(); - while (i.hasNext()) { - me = (Map.Entry) i.next(); - String loggerName = me.getKey(); - String loggerClass = me.getValue(); - - Logger logger = (Logger) LoggerFactory.getLogger(loggerClass); - TimeStampedFileAppender appender = null; - - try { - appender = (TimeStampedFileAppender) logger.getAppender(loggerName); - if (appender != null) { - File f = new File(appender.getFile()); - f.delete(); - } - } catch (Exception e) { - s_logger.warn("Can't delete {}: {}", appender.getFile(), e.getMessage()); - } - } } /** diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 00000000..a1ed037f --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cp -Rn /opt/cct/bootstrap/. /data/ +exec java -jar /opt/cct/cct.jar "$@" diff --git a/tools/85b-swing-gui/build.gradle b/tools/85b-swing-gui/build.gradle index de77a803..58afe54c 100644 --- a/tools/85b-swing-gui/build.gradle +++ b/tools/85b-swing-gui/build.gradle @@ -8,7 +8,6 @@ buildscript { plugins { id 'java-library' - id 'org.ajoberstar.grgit' version '2.1.0' id 'com.github.johnrengelman.shadow' version '4.0.4' id 'de.jjohannes.extra-java-module-info' version '0.1' @@ -57,6 +56,8 @@ dependencies { implementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' implementation 'org.junit.platform:junit-platform-launcher:1.7.0' implementation 'edu.washington.cs.types.checker:checker-framework:1.7.0' + testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.7.0' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.7.0' } extraJavaModuleInfo { diff --git a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/CompletedTestRun.java b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/CompletedTestRun.java new file mode 100644 index 00000000..3c4e8b96 --- /dev/null +++ b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/CompletedTestRun.java @@ -0,0 +1,24 @@ +package gov.gsa.pivconformance.gui; + +import java.nio.file.Path; +import java.util.Objects; + +/** Immutable inputs identifying one successfully completed CCT run. */ +public final class CompletedTestRun { + private final Path m_resultsDirectory; + private final Path m_databasePath; + private final String m_timeStampPrefix; + + public CompletedTestRun(Path resultsDirectory, Path databasePath, String timeStampPrefix) { + m_resultsDirectory = Objects.requireNonNull(resultsDirectory, "resultsDirectory").toAbsolutePath().normalize(); + m_databasePath = Objects.requireNonNull(databasePath, "databasePath").toAbsolutePath().normalize(); + m_timeStampPrefix = Objects.requireNonNull(timeStampPrefix, "timeStampPrefix"); + if (m_timeStampPrefix.trim().isEmpty()) { + throw new IllegalArgumentException("A completed run must have a timestamp prefix"); + } + } + + public Path getResultsDirectory() { return m_resultsDirectory; } + public Path getDatabasePath() { return m_databasePath; } + public String getTimeStampPrefix() { return m_timeStampPrefix; } +} diff --git a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiRunnerAppController.java b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiRunnerAppController.java index 8e6c9aaf..1ac63614 100644 --- a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiRunnerAppController.java +++ b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiRunnerAppController.java @@ -37,6 +37,7 @@ public class GuiRunnerAppController { private GuiToggleTestTreeAction m_toggleTreeAction; private GuiDisplayAboutDialogAction m_displayAboutDialogAction; private GuiDisplayTestReportAction m_displayTestReportAction; + private PackageResultsAction m_packageResultsAction; private OpenDefaultPIVDatabaseAction m_openDefaultPIVDatabaseAction; private OpenDefaultPIVIDatabaseAction m_openDefaultPIVIDatabaseAction; private GuiTestExecutionController m_tec; @@ -55,6 +56,7 @@ public void reset() { m_toggleTreeAction = null; m_displayAboutDialogAction = null; m_displayTestReportAction = null; + m_packageResultsAction = null; m_openDefaultPIVDatabaseAction = null; m_openDefaultPIVIDatabaseAction = null; m_tec = null; @@ -138,6 +140,10 @@ public GuiDisplayTestReportAction getDisplayTestReportAction() { return m_displayTestReportAction; } + public PackageResultsAction getPackageResultsAction() { + return m_packageResultsAction; + } + public void showAboutDialog() { s_logger.error("Stubbed out showAboutDialog() is still here"); } @@ -156,6 +162,9 @@ protected void createActions() { m_toggleTreeAction = new GuiToggleTestTreeAction("Toggle test tree view", toggleIcon, "Show or hide the test tree"); ImageIcon displayReportIcon = getActionIcon("html", "Display HTML report"); m_displayTestReportAction = new GuiDisplayTestReportAction("Display Test Report", displayReportIcon, "Display test report for current log"); + ImageIcon packageIcon = getActionIcon("database_save", "Package Results"); + m_packageResultsAction = new PackageResultsAction("Package Results", packageIcon, + "Create a ZIP containing the latest completed run"); ImageIcon savingIcon = getActionIcon("folder", "Saving"); ImageIcon pivIcon = getActionIcon("PIV", "Open"); m_openDefaultPIVDatabaseAction = new OpenDefaultPIVDatabaseAction("Open Default PIV Database", pivIcon, "Open Default PIV conformance test database"); diff --git a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiTestExecutionController.java b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiTestExecutionController.java index 9f8dc159..d567a8f5 100644 --- a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiTestExecutionController.java +++ b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/GuiTestExecutionController.java @@ -4,6 +4,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -106,24 +107,28 @@ public LoggerContext getLoggerContext() { public void setLoggerContext(LoggerContext ctx) { m_ctx = ctx; } - + void runAllTests(GuiTestCaseTreeNode root) { - + ConformanceTestDatabase db = GuiRunnerAppController.getInstance().getTestDatabase(); + if(db == null || db.getConnection() == null) { + s_logger.error("Unable to run tests without a valid database"); + return; + } + Path selectedDatabase = selectedDatabasePath(db); + if (selectedDatabase == null) { + s_logger.error("Unable to run tests without the selected database filename"); + return; + } + m_trlc.setStartTimes(); - + GuiDisplayTestReportAction display = GuiRunnerAppController.getInstance().getDisplayTestReportAction(); - display.setEnabled(false); + PackageResultsAction packageResults = GuiRunnerAppController.getInstance().getPackageResultsAction(); s_logger.debug("----------------------------------------"); s_logger.debug("FIPS 201 CCT " + GuiRunnerAppController.getInstance().getCctVersion()); s_logger.debug("----------------------------------------"); - ConformanceTestDatabase db = GuiRunnerAppController.getInstance().getTestDatabase(); - if(db == null || db.getConnection() == null) { - s_logger.error("Unable to run tests without a valid database"); - // XXX *** Display message don't just log it - return; - } m_running = true; GuiRunnerAppController.getInstance().reloadTree(); PCSCWrapper pcsc = PCSCWrapper.getInstance(); @@ -132,7 +137,10 @@ void runAllTests(GuiTestCaseTreeNode root) { int atomCount = 0; JProgressBar progress = m_testExecutionPanel.getTestProgressBar(); try { - SwingUtilities.invokeAndWait(() -> { + SwingUtilities.invokeAndWait(() -> { + display.setEnabled(false); + packageResults.setCompletedRun(null); + m_testExecutionPanel.setPostRunActionsVisible(false); m_testExecutionPanel.getRunButton().setEnabled(false); // TODO: Fix this or else m_toolBar.getComponents()[0].setEnabled(false); @@ -283,7 +291,30 @@ void runAllTests(GuiTestCaseTreeNode root) { CardSettingsSingleton css = CardSettingsSingleton.getInstance(); CachingDefaultPIVApplication cpiv = (CachingDefaultPIVApplication) css.getPivHandle(); cpiv.clearCache(); - display.setEnabled(true); + try { + String timeStamp = m_trlc.getTimeStamp(); + Path resultsDirectory = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + CompletedTestRun completedRun = new CompletedTestRun(resultsDirectory, selectedDatabase, timeStamp); + SwingUtilities.invokeLater(() -> { + display.setEnabled(true); + packageResults.setCompletedRun(completedRun); + m_testExecutionPanel.setPostRunActionsVisible(true); + packageResults.packageCompletedRun(); + }); + } catch (Exception e) { + s_logger.error("The completed run could not be prepared for review packaging", e); + SwingUtilities.invokeLater(() -> { + display.setEnabled(true); + JOptionPane.showMessageDialog(GuiRunnerAppController.getInstance().getMainFrame(), + "The test run finished, but its results could not be packaged.\n" + e.getMessage(), + "Run Finished", JOptionPane.ERROR_MESSAGE); + }); + } + } + + private Path selectedDatabasePath(ConformanceTestDatabase db) { + Path databasePath = db.getDatabasePath(); + return databasePath == null ? null : databasePath.toAbsolutePath().normalize(); } private void registerListeners(Launcher l, List listeners) { diff --git a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/PackageResultsAction.java b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/PackageResultsAction.java new file mode 100644 index 00000000..3ea06edc --- /dev/null +++ b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/PackageResultsAction.java @@ -0,0 +1,105 @@ +package gov.gsa.pivconformance.gui; + +import java.awt.Desktop; +import java.awt.Toolkit; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ActionEvent; +import java.io.IOException; +import java.nio.file.Path; + +import javax.swing.AbstractAction; +import javax.swing.Icon; +import javax.swing.JOptionPane; +import javax.swing.SwingWorker; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Creates a ZIP for the latest completed test run. */ +public class PackageResultsAction extends AbstractAction { + private static final long serialVersionUID = 1L; + private static final Logger s_logger = LoggerFactory.getLogger(PackageResultsAction.class); + private final ReviewPackageBuilder m_builder; + private CompletedTestRun m_completedRun; + + public PackageResultsAction(String name, Icon icon, String toolTip) { + super(name, icon); + putValue(SHORT_DESCRIPTION, toolTip); + m_builder = new ReviewPackageBuilder(); + setEnabled(false); + } + + public void setCompletedRun(CompletedTestRun completedRun) { + m_completedRun = completedRun; + setEnabled(completedRun != null); + } + + @Override + public void actionPerformed(ActionEvent event) { + packageCompletedRun(); + } + + void packageCompletedRun() { + CompletedTestRun run = m_completedRun; + if (run == null) { + showError("No completed test run is available to package."); + return; + } + setEnabled(false); + new SwingWorker() { + @Override + protected Path doInBackground() throws Exception { + return m_builder.build(run); + } + + @Override + protected void done() { + setEnabled(m_completedRun != null); + try { + Path packagePath = get(); + if (run == m_completedRun) showCompletion(packagePath); + } catch (Exception e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + s_logger.error("Unable to package test results", cause); + showError("The results could not be packaged:\n" + cause.getMessage()); + } + } + }.execute(); + } + + private void showCompletion(Path packagePath) { + Path path = packagePath.toAbsolutePath().normalize(); + Object[] options = { "Show in Folder", "Copy Path", "Close" }; + int choice = JOptionPane.showOptionDialog(GuiRunnerAppController.getInstance().getMainFrame(), + "Results packaged successfully.\n\n" + path, "Results Packaged", JOptionPane.DEFAULT_OPTION, + JOptionPane.INFORMATION_MESSAGE, null, options, options[2]); + if (choice == 0) showInFolder(path); + if (choice == 1) copyPath(path); + } + + private void showInFolder(Path path) { + try { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + throw new IOException("Opening folders is not supported on this system"); + } + Desktop.getDesktop().open(path.getParent().toFile()); + } catch (Exception e) { + showError("Unable to show the package folder:\n" + e.getMessage()); + } + } + + private void copyPath(Path path) { + try { + Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new StringSelection(path.toString()), null); + } catch (Exception e) { + s_logger.error("Unable to copy the package path", e); + showError("Unable to copy the package path. See console.log for details."); + } + } + + private void showError(String message) { + JOptionPane.showMessageDialog(GuiRunnerAppController.getInstance().getMainFrame(), message, + "Package Results Error", JOptionPane.ERROR_MESSAGE); + } +} diff --git a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/ReviewPackageBuilder.java b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/ReviewPackageBuilder.java new file mode 100644 index 00000000..b26d713a --- /dev/null +++ b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/ReviewPackageBuilder.java @@ -0,0 +1,118 @@ +package gov.gsa.pivconformance.gui; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** Packages the files belonging to one completed CCT run. */ +public class ReviewPackageBuilder { + private static final DateTimeFormatter PACKAGE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final String[] RUN_DIRECTORIES = { "logs", "piv-artifacts", "x509-artifacts" }; + + public Path build(CompletedTestRun run) throws IOException { + if (run == null) throw new IllegalStateException("No completed test run is available to package"); + Path resultsDirectory = requireDirectory(run.getResultsDirectory(), "Results directory"); + Path database = requireFile(run.getDatabasePath(), "Selected test database"); + List entries = new ArrayList<>(); + String prefix = run.getTimeStampPrefix() + "-"; + + for (String directoryName : RUN_DIRECTORIES) { + Path directory = resultsDirectory.resolve(directoryName); + if ("logs".equals(directoryName) && !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("The completed run's logs directory is unavailable: " + directory); + } + if (Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + collectFiles(resultsDirectory, directory, prefix, entries); + } + } + + long csvCount = entries.stream() + .filter(entry -> entry.name.startsWith("logs/") && entry.name.toLowerCase().endsWith(".csv")) + .count(); + if (csvCount != 1) { + throw new IOException("Expected exactly one conformance CSV for the completed run, but found " + csvCount); + } + + Path trustDirectory = requireDirectory(resultsDirectory.resolve("x509-certs"), "Trust-path directory"); + int entryCount = entries.size(); + collectFiles(resultsDirectory, trustDirectory, null, entries); + if (entries.size() == entryCount) { + throw new IOException("The trust-path directory contains no files: " + trustDirectory); + } + entries.add(new SourceEntry(database, database.getFileName().toString())); + entries.sort(Comparator.comparing(entry -> entry.name)); + + Path target = uniqueTarget(resultsDirectory); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(target, StandardOpenOption.CREATE_NEW))) { + for (SourceEntry source : entries) { + zip.putNextEntry(new ZipEntry(source.name)); + Files.copy(source.path, zip); + zip.closeEntry(); + } + } catch (IOException e) { + Files.deleteIfExists(target); + throw e; + } + return target; + } + + private static void collectFiles(Path base, Path directory, String prefix, List entries) + throws IOException { + try (Stream paths = Files.walk(directory)) { + Iterator iterator = paths.iterator(); + while (iterator.hasNext()) { + Path file = iterator.next(); + if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) + && (prefix == null || file.getFileName().toString().startsWith(prefix))) { + String name = base.relativize(file).toString().replace('\\', '/'); + entries.add(new SourceEntry(file, name)); + } + } + } + } + + private static Path requireDirectory(Path path, String description) throws IOException { + Path normalized = path.toAbsolutePath().normalize(); + if (!Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException(description + " is unavailable: " + normalized); + } + return normalized; + } + + private static Path requireFile(Path path, String description) throws IOException { + Path normalized = path.toAbsolutePath().normalize(); + if (!Files.isRegularFile(normalized, LinkOption.NOFOLLOW_LINKS) || !Files.isReadable(normalized)) { + throw new IOException(description + " is unavailable: " + normalized); + } + return normalized; + } + + private static Path uniqueTarget(Path directory) { + String baseName = "cct-results-" + PACKAGE_TIME.format(LocalDateTime.now()); + Path candidate = directory.resolve(baseName + ".zip"); + int suffix = 2; + while (Files.exists(candidate)) candidate = directory.resolve(baseName + "-" + suffix++ + ".zip"); + return candidate; + } + + private static final class SourceEntry { + private final Path path; + private final String name; + + private SourceEntry(Path path, String name) { + this.path = path; + this.name = name; + } + } +} diff --git a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/SimpleTestExecutionPanel.java b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/SimpleTestExecutionPanel.java index 35dd89b0..f185c755 100644 --- a/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/SimpleTestExecutionPanel.java +++ b/tools/85b-swing-gui/src/main/java/gov/gsa/pivconformance/gui/SimpleTestExecutionPanel.java @@ -9,8 +9,7 @@ import java.awt.Color; import java.awt.HeadlessException; -import java.sql.Connection; -import java.sql.SQLException; +import java.nio.file.Path; import java.util.List; import javax.swing.JLabel; @@ -55,6 +54,8 @@ public class SimpleTestExecutionPanel extends JPanel { private final JTextField m_readerStatusField; private final JProgressBar m_testProgressBar; private final JButton m_runButton; + private final JButton m_viewResultsButton; + private final JButton m_packageResultsButton; public SimpleTestExecutionPanel() { setBackground(Color.WHITE); @@ -199,6 +200,16 @@ public void actionPerformed(ActionEvent e) { } }); + + m_viewResultsButton = new JButton(GuiRunnerAppController.getInstance().getDisplayTestReportAction()); + m_viewResultsButton.setText("View Results"); + m_viewResultsButton.setIcon(null); + m_viewResultsButton.setVisible(false); + + m_packageResultsButton = new JButton(GuiRunnerAppController.getInstance().getPackageResultsAction()); + m_packageResultsButton.setText("Package Results"); + m_packageResultsButton.setIcon(null); + m_packageResultsButton.setVisible(false); m_testProgressBar = new JProgressBar(); m_testProgressBar.setAlignmentY(Component.TOP_ALIGNMENT); @@ -270,7 +281,11 @@ public void actionPerformed(ActionEvent e) { .addComponent(btnRefreshReaders, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) .addPreferredGap(ComponentPlacement.RELATED) .addComponent(m_runButton) - .addGap(317)) + .addPreferredGap(ComponentPlacement.RELATED) + .addComponent(m_viewResultsButton) + .addPreferredGap(ComponentPlacement.RELATED) + .addComponent(m_packageResultsButton) + .addGap(106)) ); groupLayout.setVerticalGroup( groupLayout.createParallelGroup(Alignment.LEADING) @@ -310,7 +325,9 @@ public void actionPerformed(ActionEvent e) { .addPreferredGap(ComponentPlacement.UNRELATED) .addGroup(groupLayout.createParallelGroup(Alignment.BASELINE) .addComponent(btnRefreshReaders) - .addComponent(m_runButton)) + .addComponent(m_runButton) + .addComponent(m_viewResultsButton) + .addComponent(m_packageResultsButton)) .addContainerGap(GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) ); setLayout(groupLayout); @@ -326,16 +343,10 @@ public void actionPerformed(ActionEvent e) { public void refreshDatabaseInfo() { ConformanceTestDatabase db = GuiRunnerAppController.getInstance().getTestDatabase(); if(db != null) { - Connection c = db.getConnection(); - if(c != null) { - String filename = null; - try { - filename = c.getClientInfo("filename"); - } catch (SQLException e) { - m_databaseNameField.setText("No filename information is available."); - } - if(filename != null) { - m_databaseNameField.setText(filename); + if(db.getConnection() != null) { + Path databasePath = db.getDatabasePath(); + if(databasePath != null) { + m_databaseNameField.setText(databasePath.toString()); } else { m_databaseNameField.setText("(unavailable)"); } @@ -380,6 +391,13 @@ public JButton getRunButton() { return m_runButton; } + public void setPostRunActionsVisible(boolean visible) { + m_viewResultsButton.setVisible(visible); + m_packageResultsButton.setVisible(visible); + revalidate(); + repaint(); + } + public void refreshReaderStatus(CardSettingsSingleton css) { CardTerminal reader = css.getTerminal(); if(reader == null) { diff --git a/tools/85b-swing-gui/src/test/java/gov/gsa/pivconformance/conformancelib/utilities/TestRunLogControllerLifecycleTest.java b/tools/85b-swing-gui/src/test/java/gov/gsa/pivconformance/conformancelib/utilities/TestRunLogControllerLifecycleTest.java new file mode 100644 index 00000000..c681f215 --- /dev/null +++ b/tools/85b-swing-gui/src/test/java/gov/gsa/pivconformance/conformancelib/utilities/TestRunLogControllerLifecycleTest.java @@ -0,0 +1,37 @@ +package gov.gsa.pivconformance.conformancelib.utilities; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TestRunLogControllerLifecycleTest { + private static final String CSV_HEADER = "Date,Test Id,Description,Expected Result,Actual Result" + + System.lineSeparator(); + + @TempDir + Path tempDirectory; + + @Test + void resetsBaseLogsBeforeEveryRunWithoutCarryingPreviousResults() throws Exception { + Path csv = tempDirectory.resolve("logs/conformancelog/conformance_results.csv"); + Path apdu = tempDirectory.resolve("logs/apdu/apdu_transmission.log"); + + TestRunLogController.resetLogFile(csv, true); + TestRunLogController.resetLogFile(apdu, false); + Files.writeString(csv, "first-run-result" + System.lineSeparator(), StandardCharsets.UTF_8, + StandardOpenOption.APPEND); + Files.writeString(apdu, "first-run-apdu", StandardCharsets.UTF_8, StandardOpenOption.APPEND); + + TestRunLogController.resetLogFile(csv, true); + TestRunLogController.resetLogFile(apdu, false); + + assertEquals(CSV_HEADER, Files.readString(csv, StandardCharsets.UTF_8)); + assertEquals("", Files.readString(apdu, StandardCharsets.UTF_8)); + } +} diff --git a/tools/85b-swing-gui/src/test/java/gov/gsa/pivconformance/gui/ReviewPackageBuilderTest.java b/tools/85b-swing-gui/src/test/java/gov/gsa/pivconformance/gui/ReviewPackageBuilderTest.java new file mode 100644 index 00000000..7c1ff213 --- /dev/null +++ b/tools/85b-swing-gui/src/test/java/gov/gsa/pivconformance/gui/ReviewPackageBuilderTest.java @@ -0,0 +1,163 @@ +package gov.gsa.pivconformance.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import gov.gsa.pivconformance.conformancelib.configuration.ConformanceTestDatabase; + +class ReviewPackageBuilderTest { + private static final String PREFIX = "card-identifier_20260819_010203-20260819_020304"; + + @TempDir + Path tempDirectory; + + @ParameterizedTest + @ValueSource(strings = { "PIV_Production_Cards.db", "PIV-I_Production_Cards.db" }) + void packagesExactlyOneCompletedRunAndSelectedDatabase(String databaseName) throws Exception { + Path database = createEvidence(databaseName, "database"); + write("logs/conformancelog/" + PREFIX + "-conformance_results.csv", + "Date,Test Id,Description,Expected Result,Actual Result\n" + + "2026-08-19 01:02:04,1,one,Pass,Pass\n" + + "2026-08-19 01:02:05,2,two,Pass,Fail\n"); + write("logs/apdu/" + PREFIX + "-apdu_transmission.log", "masked APDU aa aa aa"); + write("logs/conformancelog/old-run-conformance_results.csv", "stale"); + write("logs/debug/debug.log", "temporary"); + write("piv-artifacts/" + PREFIX + "-chuid.bin", "current piv artifact"); + write("piv-artifacts/old-run-chuid.bin", "stale piv artifact"); + write("x509-artifacts/" + PREFIX + "-authentication.crt", "current certificate artifact"); + write("x509-certs/cacerts.jks", "trust store"); + write("x509-certs/valid/policy.xml", "policy"); + write("unused.db", "unused database"); + write("tool.jar", "executable"); + write("cct-results-20200101-000000.zip", "old package"); + + Path result = new ReviewPackageBuilder().build(completedRun(database)); + + assertTrue(result.getFileName().toString().startsWith("cct-results-")); + assertTrue(result.getFileName().toString().endsWith(".zip")); + assertEquals(Arrays.asList( + databaseName, + "logs/apdu/" + PREFIX + "-apdu_transmission.log", + "logs/conformancelog/" + PREFIX + "-conformance_results.csv", + "piv-artifacts/" + PREFIX + "-chuid.bin", + "x509-artifacts/" + PREFIX + "-authentication.crt", + "x509-certs/cacerts.jks", + "x509-certs/valid/policy.xml"), zipEntries(result)); + assertFalse(result.getFileName().toString().contains("card-identifier")); + } + + @Test + void preservesLegacyEvidenceBytesUnchanged() throws Exception { + Path database = createEvidence("PIV_ICAM_Test_Cards.db", "database"); + basicCsv(); + byte[] evidence = new byte[] { 0x00, 0x31, 0x32, 0x33, 0x34, (byte) 0xff, 0x0a }; + Path apdu = writeBytes("logs/apdu/" + PREFIX + "-apdu_transmission.log", evidence); + Path result = new ReviewPackageBuilder().build(completedRun(database)); + assertEquals(Arrays.toString(Files.readAllBytes(apdu)), + Arrays.toString(zipEntry(result, "logs/apdu/" + apdu.getFileName()))); + } + + @Test + void packageActionTracksCompletedRunAvailability() throws Exception { + PackageResultsAction action = new PackageResultsAction("Package", null, "Package results"); + assertFalse(action.isEnabled()); + + Path database = createEvidence("PIV_Production_Cards.db", "database"); + basicCsv(); + action.setCompletedRun(completedRun(database)); + assertTrue(action.isEnabled()); + + action.setCompletedRun(null); + assertFalse(action.isEnabled()); + } + + @Test + void retainsCanonicalSelectedDatabasePathWithoutJdbcClientInfo() throws Exception { + Path database = tempDirectory.resolve("databases").resolve("selected.db"); + Files.createDirectories(database.getParent()); + Files.createFile(database); + Path nonCanonicalPath = database.getParent().resolve("..").resolve("databases").resolve("selected.db"); + ConformanceTestDatabase selected = new ConformanceTestDatabase(null); + + try { + selected.openDatabaseInFile(nonCanonicalPath.toString()); + assertEquals(database.toRealPath(), selected.getDatabasePath()); + } finally { + if (selected.getConnection() != null) selected.getConnection().close(); + } + } + + @Test + void rejectsMissingCompletedRunAndAmbiguousCsv() throws Exception { + ReviewPackageBuilder builder = new ReviewPackageBuilder(); + assertThrows(IllegalStateException.class, () -> builder.build(null)); + + Path database = createEvidence("PIV_Production_Cards.db", "database"); + basicCsv(); + write("logs/other/" + PREFIX + "-second.csv", "Date,Actual Result\nnow,Pass\n"); + CompletedTestRun run = completedRun(database); + IOException error = assertThrows(IOException.class, () -> builder.build(run)); + assertTrue(error.getMessage().contains("exactly one conformance CSV")); + } + + private Path basicCsv() throws IOException { + createEvidence("x509-certs/cacerts.jks", "trust"); + return write("logs/conformancelog/" + PREFIX + "-conformance_results.csv", + "Date,Test Id,Description,Expected Result,Actual Result\n" + + "now,1,one,Pass,Pass\nnow,2,two,Pass,Fail\n"); + } + + private CompletedTestRun completedRun(Path database) { + return new CompletedTestRun(tempDirectory, database, PREFIX); + } + + private Path createEvidence(String relative, String contents) throws IOException { + return write(relative, contents); + } + + private Path write(String relative, String contents) throws IOException { + return writeBytes(relative, contents.getBytes(StandardCharsets.UTF_8)); + } + + private Path writeBytes(String relative, byte[] contents) throws IOException { + Path path = tempDirectory.resolve(relative); + Files.createDirectories(path.getParent()); + Files.write(path, contents); + return path; + } + + private static List zipEntries(Path zipPath) throws IOException { + List names = new ArrayList<>(); + try (ZipFile zip = new ZipFile(zipPath.toFile())) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) names.add(entries.nextElement().getName()); + } + return names; + } + + private static byte[] zipEntry(Path zipPath, String name) throws IOException { + try (ZipFile zip = new ZipFile(zipPath.toFile())) { + ZipEntry entry = zip.getEntry(name); + assertTrue(entry != null, name); + return zip.getInputStream(entry).readAllBytes(); + } + } +}