This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 2611fdfdda add PGP signing tests, refs #8206 and #7276 (#8213)
2611fdfdda is described below
commit 2611fdfdda611c51d2d710a38004edb9bf6f12b1
Author: Bart Maertens <[email protected]>
AuthorDate: Wed Sep 2 09:49:29 2026 +0200
add PGP signing tests, refs #8206 and #7276 (#8213)
The PGP encrypt dialog filled its grid in column order but read it back
one column out of step, so opening a working action and pressing OK moved
the wildcard into the User ID and the signing key into the destination
path. Broken in 2.18.0 by #6940, fixed in 2.18.1 by #7282. These tests pin
that down and prove signing works end to end.
- ActionPGPEncryptFilesDialogTest: SWTBot coverage of the grid round trip,
verified to fail against the 2.18.0 column mapping
- ActionPGPEncryptFilesSignTest: sign/verify and sign+encrypt/decrypt
against a real gpg and a throwaway keyring, skipped when gpg is absent
- integration-tests/pgp: the same two round trips as hop-run workflows
- gnupg added to the integration-test image; ubuntu:22.04 ships only gpgv
---
.gitignore | 2 +
docker/integration-tests/unit-tests.Dockerfile | 1 +
integration-tests/pgp/dev-env-config.json | 3 +
integration-tests/pgp/hop-config.json | 290 +++++++++++++++++++++
.../pgp/main-0001-pgp-sign-and-verify.hwf | 224 ++++++++++++++++
.../pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf | 278 ++++++++++++++++++++
.../metadata/pipeline-run-configuration/local.json | 20 ++
.../metadata/workflow-run-configuration/local.json | 11 +
integration-tests/pgp/project-config.json | 15 ++
.../ActionPGPEncryptFilesDialogTest.java | 176 +++++++++++++
.../ActionPGPEncryptFilesSignTest.java | 275 +++++++++++++++++++
11 files changed, 1295 insertions(+)
diff --git a/.gitignore b/.gitignore
index c8b5771d33..3c419065be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,6 +67,8 @@ integration-tests/http/files/http-action-output
integration-tests/http/output/
# SFTP tests download and move files into output/ while they run
integration-tests/sftp/output/
+# The PGP tests build a throwaway keyring and the files they sign under output/
+integration-tests/pgp/output/
integration-tests/spreadsheet/files/sample-file-append.xlsx
integration-tests/spreadsheet/files/sample-file-append-test.xlsx
integration-tests/spark-native/output
diff --git a/docker/integration-tests/unit-tests.Dockerfile
b/docker/integration-tests/unit-tests.Dockerfile
index 395ed96df9..18d71d3941 100644
--- a/docker/integration-tests/unit-tests.Dockerfile
+++ b/docker/integration-tests/unit-tests.Dockerfile
@@ -54,6 +54,7 @@ RUN apt-get update \
curl \
procps \
git \
+ gnupg \
python3-pip \
openjdk-21-jre-headless \
unzip \
diff --git a/integration-tests/pgp/dev-env-config.json
b/integration-tests/pgp/dev-env-config.json
new file mode 100644
index 0000000000..0463fd4e3a
--- /dev/null
+++ b/integration-tests/pgp/dev-env-config.json
@@ -0,0 +1,3 @@
+{
+ "variables" : [ ]
+}
diff --git a/integration-tests/pgp/hop-config.json
b/integration-tests/pgp/hop-config.json
new file mode 100644
index 0000000000..d9e1e6562e
--- /dev/null
+++ b/integration-tests/pgp/hop-config.json
@@ -0,0 +1,290 @@
+{
+ "variables": [
+ {
+ "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+ "value": "N",
+ "description": "System wide flag to allow lenient string to number
conversion for backward compatibility. If this setting is set to \"Y\", an
string starting with digits will be converted successfully into a number.
(example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending
on the decimal and grouping symbol). The default (N) will be to throw an error
if non-numeric symbols are found in the string."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+ "value": "N",
+ "description": "System wide flag to ignore timezone while writing
date/timestamp value to the database."
+ },
+ {
+ "name": "HOP_LOG_SIZE_LIMIT",
+ "value": "0",
+ "description": "The log size limit for all pipelines and workflows that
don't have the \"log size limit\" property set in their respective properties."
+ },
+ {
+ "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+ "value": "N",
+ "description": "NULL vs Empty String. If this setting is set to Y, an
empty string and null are different. Otherwise they are not."
+ },
+ {
+ "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+ "value": "0",
+ "description": "The maximum number of log lines that are kept internally
by Hop. Set to 0 to keep all rows (default)"
+ },
+ {
+ "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+ "value": "1440",
+ "description": "The maximum age (in minutes) of a log line while being
kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+ },
+ {
+ "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+ "value": "5000",
+ "description": "The maximum number of workflow trackers kept in memory"
+ },
+ {
+ "name": "HOP_MAX_ACTIONS_LOGGED",
+ "value": "5000",
+ "description": "The maximum number of action results kept in memory for
logging purposes."
+ },
+ {
+ "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+ "value": "10000",
+ "description": "The maximum number of logging registry entries kept in
memory for logging purposes."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_DELAY",
+ "value": "1000",
+ "description": "The hop log tab refresh delay."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+ "value": "1000",
+ "description": "The hop log tab refresh period."
+ },
+ {
+ "name": "HOP_PLUGIN_CLASSES",
+ "value": null,
+ "description": "A comma delimited list of classes to scan for plugin
annotations"
+ },
+ {
+ "name": "HOP_PLUGIN_PACKAGES",
+ "value": null,
+ "description": "A comma delimited list of packages to scan for plugin
annotations (warning: slow!!)"
+ },
+ {
+ "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+ "value": "0",
+ "description": "The maximum number of transform performance snapshots to
keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+ },
+ {
+ "name": "HOP_ROWSET_GET_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an
alternative rowset get timeout (in ms). This only makes a difference for
extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_ROWSET_PUT_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an
alternative rowset put timeout (in ms). This only makes a difference for
extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_CORE_TRANSFORMS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the
alternative location of the hop-transforms.xml file. You can use this to
customize the list of available internal transforms outside of the codebase."
+ },
+ {
+ "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the
alternative location of the hop-workflow-actions.xml file."
+ },
+ {
+ "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+ "value": "1440",
+ "description": "This project variable will set a time-out after which
waiting, completed or stopped pipelines and workflows will be automatically
cleaned up. The default value is 1440 (one day)."
+ },
+ {
+ "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+ "value": null,
+ "description": "Set this variable to an integer that will be returned as
the Pan JVM exit code."
+ },
+ {
+ "name": "HOP_DISABLE_CONSOLE_LOGGING",
+ "value": "N",
+ "description": "Set this variable to Y to disable standard Hop logging
to the console. (stdout)"
+ },
+ {
+ "name": "HOP_REDIRECT_STDERR",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stderr to Hop
logging."
+ },
+ {
+ "name": "HOP_REDIRECT_STDOUT",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stdout to Hop
logging."
+ },
+ {
+ "name": "HOP_DEFAULT_NUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default number format"
+ },
+ {
+ "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default bignumber format"
+ },
+ {
+ "name": "HOP_DEFAULT_INTEGER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default integer format"
+ },
+ {
+ "name": "HOP_DEFAULT_DATE_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default date format"
+ },
+ {
+ "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default timestamp format"
+ },
+ {
+ "name": "HOP_DEFAULT_SERVLET_ENCODING",
+ "value": null,
+ "description": "Defines the default encoding for servlets, leave it
empty to use Java default encoding"
+ },
+ {
+ "name": "HOP_FAIL_ON_LOGGING_ERROR",
+ "value": "N",
+ "description": "Set this variable to Y when you want the
workflow/pipeline fail with an error when the related logging process (e.g. to
a database) fails."
+ },
+ {
+ "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+ "value": "N",
+ "description": "Set this variable to Y to set the minimum to NULL if
NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN
aggregate and MIN is set to the minimum value that is not NULL. See also the
variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+ },
+ {
+ "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+ "value": "N",
+ "description": "Set this variable to Y to return 0 when all values
within an aggregate are NULL. Otherwise by default a NULL is returned when all
values are NULL."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for
the Text File Output transform. Setting this to Ywill add no header row at all
when the append option is enabled, regardless if the file is existing or not."
+ },
+ {
+ "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+ "value": "Hop",
+ "description": "Specifies the password encoder plugin to use by ID (Hop
is the default)."
+ },
+ {
+ "name": "HOP_SYSTEM_HOSTNAME",
+ "value": null,
+ "description": "You can use this variable to speed up hostname lookup.
Hostname lookup is performed by Hop so that it is capable of logging the server
on which a workflow or pipeline is executed."
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPTORS",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptors for
Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptQueueSize
for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+ "value": null,
+ "description": "A variable to configure jetty option:
lowResourcesMaxIdleTime for Carte"
+ },
+ {
+ "name":
"HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for
the Merge Rows (diff) transform. Setting this to Y will use the data from the
reference stream (instead of the comparison stream) in case the compared rows
are identical."
+ },
+ {
+ "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+ "value": "false",
+ "description": "Set this variable to false to preserve enclosure symbol
after splitting the string in the Split fields transform. Changing it to true
will remove first and last enclosure symbol from the resulting string chunks."
+ },
+ {
+ "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+ "value": "false",
+ "description": "Set this variable to TRUE to allow your pipeline to pass
'null' fields and/or empty types."
+ },
+ {
+ "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+ "value": "false",
+ "description": "Set this variable to false to preserve global log
variables defined in pipeline / workflow Properties -> Log panel. Changing it
to true will clear it when export pipeline / workflow."
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+ "value": "1024",
+ "description": "This project variable is used by the Text File Output
transform. It defines the max number of simultaneously open files within the
transform. The transform will close/reopen files as necessary to insure the max
is not exceeded"
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+ "value": "0",
+ "description": "This project variable is used by the Text File Output
transform. It defines the max number of milliseconds between flushes of files
opened by the transform."
+ },
+ {
+ "name": "HOP_USE_NATIVE_FILE_DIALOG",
+ "value": "N",
+ "description": "Set this value to Y if you want to use the system file
open/save dialog when browsing files"
+ },
+ {
+ "name": "HOP_AUTO_CREATE_CONFIG",
+ "value": "Y",
+ "description": "Set this value to N if you don't want to automatically
create a hop configuration file (hop-config.json) when it's missing"
+ }
+ ],
+ "LocaleDefault": "en_BE",
+ "guiProperties": {
+ "FontFixedSize": "13",
+ "MaxUndo": "100",
+ "DarkMode": "Y",
+ "FontNoteSize": "13",
+ "ShowOSLook": "Y",
+ "FontFixedStyle": "0",
+ "FontNoteName": ".AppleSystemUIFont",
+ "FontFixedName": "Monospaced",
+ "FontGraphStyle": "0",
+ "FontDefaultSize": "13",
+ "GraphColorR": "255",
+ "FontGraphSize": "13",
+ "IconSize": "32",
+ "BackgroundColorB": "255",
+ "FontNoteStyle": "0",
+ "FontGraphName": ".AppleSystemUIFont",
+ "FontDefaultName": ".AppleSystemUIFont",
+ "GraphColorG": "255",
+ "UseGlobalFileBookmarks": "Y",
+ "FontDefaultStyle": "0",
+ "GraphColorB": "255",
+ "BackgroundColorR": "255",
+ "BackgroundColorG": "255",
+ "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+ "LineWidth": "1",
+ "ContextDialogShowCategories": "Y"
+ },
+ "projectsConfig": {
+ "enabled": true,
+ "projectMandatory": true,
+ "environmentMandatory": false,
+ "defaultProject": "default",
+ "defaultEnvironment": null,
+ "standardParentProject": "default",
+ "standardProjectsFolder": null,
+ "projectConfigurations": [
+ {
+ "projectName": "default",
+ "projectHome": "${HOP_CONFIG_FOLDER}",
+ "configFilename": "project-config.json"
+ }
+ ],
+ "lifecycleEnvironments": [
+ {
+ "name": "dev",
+ "purpose": "Testing",
+ "projectName": "default",
+ "configurationFiles": [
+ "${PROJECT_HOME}/dev-env-config.json"
+ ]
+ }
+ ],
+ "projectLifecycles": []
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/pgp/main-0001-pgp-sign-and-verify.hwf
b/integration-tests/pgp/main-0001-pgp-sign-and-verify.hwf
new file mode 100644
index 0000000000..998e2c3ceb
--- /dev/null
+++ b/integration-tests/pgp/main-0001-pgp-sign-and-verify.hwf
@@ -0,0 +1,224 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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.
+
+-->
+<workflow>
+ <name>main-0001-pgp-sign-and-verify</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/01 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>80</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>create a throwaway keyring</name>
+ <description/>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory>${PROJECT_HOME}</work_directory>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>#!/bin/bash
+set -e
+
+# A throwaway keyring under output/, so a test run never touches the keyring
of the machine
+# or container it happens to run on.
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+ echo "gpg is not installed; the PGP actions cannot run without it"
+ exit 1
+fi
+
+rm -rf "$GNUPG_HOME"
+mkdir -p "$GNUPG_HOME"
+chmod 700 "$GNUPG_HOME"
+
+# gpg is started by the Hop JVM and inherits its environment, and a Java
process cannot change
+# its own, so GNUPGHOME can never reach it from here. Pin the keyring in a
wrapper instead and
+# point the PGP actions at that.
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME"
> "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+# The key carries no passphrase: the PGP encrypt action has no passphrase
field, so signing can
+# only ever use a key gpg is able to unlock on its own.
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback
--passphrase '' \
+ --quiet --quick-generate-key "Hop Integration Test
<[email protected]>" default default never 2>&1
+
+printf 'id,amount\n1,100\n2,200\n' > ${PROJECT_HOME}/output/report.csv
+rm -f ${PROJECT_HOME}/output/report.csv.asc
${PROJECT_HOME}/output/report-unused.csv
+</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>240</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>sign the file</name>
+ <description/>
+ <type>PGP_ENCRYPT_FILES</type>
+ <attributes/>
+ <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+ <arg_from_previous>N</arg_from_previous>
+ <include_subfolders>N</include_subfolders>
+ <add_result_filesname>N</add_result_filesname>
+ <destination_is_a_file>Y</destination_is_a_file>
+ <create_destination_folder>N</create_destination_folder>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <SpecifyFormat>N</SpecifyFormat>
+ <date_time_format/>
+ <nr_errors_less_than>10</nr_errors_less_than>
+ <success_condition>success_if_no_errors</success_condition>
+ <AddDateBeforeExtension>N</AddDateBeforeExtension>
+ <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+ <iffileexists>overwrite_file</iffileexists>
+ <destinationFolder/>
+ <ifmovedfileexists>do_nothing</ifmovedfileexists>
+ <moved_date_time_format/>
+ <create_move_to_folder>N</create_move_to_folder>
+ <add_moved_date>N</add_moved_date>
+ <add_moved_time>N</add_moved_time>
+ <SpecifyMoveFormat>N</SpecifyMoveFormat>
+ <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+ <asciiMode>Y</asciiMode>
+ <fields>
+ <field>
+ <action_type>sign</action_type>
+
<source_filefolder>${PROJECT_HOME}/output/report.csv</source_filefolder>
+ <wildcard/>
+ <userid></userid>
+
<destination_filefolder>${PROJECT_HOME}/output/report.csv.asc</destination_filefolder>
+ </field>
+ </fields>
+ <parallel>N</parallel>
+ <xloc>460</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>verify the signature</name>
+ <description/>
+ <type>PGP_VERIFY_FILES</type>
+ <attributes/>
+ <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+ <filename>${PROJECT_HOME}/output/report.csv.asc</filename>
+ <detachedfilename/>
+ <useDetachedSignature>N</useDetachedSignature>
+ <parallel>N</parallel>
+ <xloc>680</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Abort workflow</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <message>Signing a file with PGP or verifying its signature
failed</message>
+ <loglevel>ERROR</loglevel>
+ <always_log_rows>N</always_log_rows>
+ <parallel>N</parallel>
+ <xloc>460</xloc>
+ <yloc>220</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>create a throwaway keyring</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>create a throwaway keyring</from>
+ <to>sign the file</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>sign the file</from>
+ <to>verify the signature</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>create a throwaway keyring</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>sign the file</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>verify the signature</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf
b/integration-tests/pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf
new file mode 100644
index 0000000000..7def46f7a0
--- /dev/null
+++ b/integration-tests/pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf
@@ -0,0 +1,278 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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.
+
+-->
+<workflow>
+ <name>main-0002-pgp-sign-encrypt-and-decrypt</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/09/01 00:00:00.000</created_date>
+ <modified_date>2026/09/01 00:00:00.000</modified_date>
+ <created_hop_version/>
+ <modified_hop_version>2.20.0-SNAPSHOT</modified_hop_version>
+ <workflow_version/>
+ <parameters/>
+ <actions>
+ <action>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <DayOfMonth>1</DayOfMonth>
+ <weekDay>1</weekDay>
+ <minutes>0</minutes>
+ <hour>12</hour>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <xloc>80</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename/>
+ <work_directory>${PROJECT_HOME}</work_directory>
+ <arguments/>
+ <arg_from_previous>N</arg_from_previous>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <exec_per_row>N</exec_per_row>
+ <set_append_logfile>N</set_append_logfile>
+ <insertScript>Y</insertScript>
+ <script>#!/bin/bash
+set -e
+
+# A throwaway keyring under output/, so a test run never touches the keyring
of the machine
+# or container it happens to run on.
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+ echo "gpg is not installed; the PGP actions cannot run without it"
+ exit 1
+fi
+
+rm -rf "$GNUPG_HOME"
+mkdir -p "$GNUPG_HOME"
+chmod 700 "$GNUPG_HOME"
+
+# gpg is started by the Hop JVM and inherits its environment, and a Java
process cannot change
+# its own, so GNUPGHOME can never reach it from here. Pin the keyring in a
wrapper instead and
+# point the PGP actions at that.
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" >
"$WRAPPER"
+chmod 700 "$WRAPPER"
+
+# The key carries no passphrase: the PGP encrypt action has no passphrase
field, so signing can
+# only ever use a key gpg is able to unlock on its own.
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback
--passphrase '' \
+ --quiet --quick-generate-key "Hop Integration Test <[email protected]>"
default default never 2>&1
+
+printf 'id,amount\n1,100\n2,200\n' > ${PROJECT_HOME}/output/payments.csv
+rm -f ${PROJECT_HOME}/output/payments.csv.asc
${PROJECT_HOME}/output/payments-decrypted.csv
+</script>
+ <name>create a throwaway keyring</name>
+ <description/>
+ <type>SHELL</type>
+ <attributes/>
+ <xloc>240</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <arg_from_previous>N</arg_from_previous>
+ <include_subfolders>N</include_subfolders>
+ <add_result_filesname>N</add_result_filesname>
+ <destination_is_a_file>Y</destination_is_a_file>
+ <create_destination_folder>N</create_destination_folder>
+ <fields>
+ <field>
+ <action_type>signandencrypt</action_type>
+
<source_filefolder>${PROJECT_HOME}/output/payments.csv</source_filefolder>
+ <userid>[email protected]</userid>
+
<destination_filefolder>${PROJECT_HOME}/output/payments.csv.asc</destination_filefolder>
+ <wildcard/>
+ </field>
+ </fields>
+ <nr_errors_less_than>10</nr_errors_less_than>
+ <success_condition>success_if_no_errors</success_condition>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <SpecifyFormat>N</SpecifyFormat>
+ <date_time_format/>
+ <AddDateBeforeExtension>N</AddDateBeforeExtension>
+ <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+ <iffileexists>overwrite_file</iffileexists>
+ <destinationFolder/>
+ <ifmovedfileexists>do_nothing</ifmovedfileexists>
+ <moved_date_time_format/>
+ <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+ <add_moved_date>N</add_moved_date>
+ <add_moved_time>N</add_moved_time>
+ <SpecifyMoveFormat>N</SpecifyMoveFormat>
+ <create_move_to_folder>N</create_move_to_folder>
+ <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+ <asciiMode>Y</asciiMode>
+ <name>sign and encrypt the file</name>
+ <description/>
+ <type>PGP_ENCRYPT_FILES</type>
+ <attributes/>
+ <xloc>460</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <arg_from_previous>N</arg_from_previous>
+ <include_subfolders>N</include_subfolders>
+ <add_result_filesname>N</add_result_filesname>
+ <destination_is_a_file>Y</destination_is_a_file>
+ <create_destination_folder>N</create_destination_folder>
+ <fields>
+ <field>
+
<source_filefolder>${PROJECT_HOME}/output/payments.csv.asc</source_filefolder>
+ <passphrase>Encrypted </passphrase>
+
<destination_filefolder>${PROJECT_HOME}/output/payments-decrypted.csv</destination_filefolder>
+ <wildcard/>
+ </field>
+ </fields>
+ <nr_errors_less_than>10</nr_errors_less_than>
+ <success_condition>success_if_no_errors</success_condition>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <SpecifyFormat>N</SpecifyFormat>
+ <date_time_format/>
+ <AddDateBeforeExtension>N</AddDateBeforeExtension>
+ <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+ <iffileexists>overwrite_file</iffileexists>
+ <destinationFolder/>
+ <ifmovedfileexists>do_nothing</ifmovedfileexists>
+ <moved_date_time_format/>
+ <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+ <add_moved_date>N</add_moved_date>
+ <add_moved_time>N</add_moved_time>
+ <SpecifyMoveFormat>N</SpecifyMoveFormat>
+ <create_move_to_folder>N</create_move_to_folder>
+ <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+ <name>decrypt the file</name>
+ <description/>
+ <type>PGP_DECRYPT_FILES</type>
+ <attributes/>
+ <xloc>680</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename1>${PROJECT_HOME}/output/payments.csv</filename1>
+ <filename2>${PROJECT_HOME}/output/payments-decrypted.csv</filename2>
+ <add_filename_result>N</add_filename_result>
+ <name>the round trip preserved the file</name>
+ <description/>
+ <type>FILE_COMPARE</type>
+ <attributes/>
+ <xloc>900</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <message>Signing and encrypting a file with PGP did not survive the
round trip</message>
+ <loglevel>Error</loglevel>
+ <name>Abort workflow</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <xloc>460</xloc>
+ <yloc>220</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>create a throwaway keyring</to>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>create a throwaway keyring</from>
+ <to>sign and encrypt the file</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>sign and encrypt the file</from>
+ <to>decrypt the file</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>decrypt the file</from>
+ <to>the round trip preserved the file</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>create a throwaway keyring</from>
+ <to>Abort workflow</to>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>sign and encrypt the file</from>
+ <to>Abort workflow</to>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>decrypt the file</from>
+ <to>Abort workflow</to>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>the round trip preserved the file</from>
+ <to>Abort workflow</to>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/pgp/metadata/pipeline-run-configuration/local.json
b/integration-tests/pgp/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 0000000000..d6b7ca4eed
--- /dev/null
+++ b/integration-tests/pgp/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,20 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "feedback_size": "50000",
+ "sample_size": "100",
+ "sample_type_in_gui": "Last",
+ "wait_time": "20",
+ "rowset_size": "10000",
+ "safe_mode": false,
+ "show_feedback": false,
+ "topo_sort": false,
+ "gather_metrics": false,
+ "transactional": false
+ }
+ },
+ "defaultSelection": true,
+ "configurationVariables": [],
+ "name": "local",
+ "description": "Runs your pipelines locally with the standard local Hop
pipeline engine"
+}
\ No newline at end of file
diff --git
a/integration-tests/pgp/metadata/workflow-run-configuration/local.json
b/integration-tests/pgp/metadata/workflow-run-configuration/local.json
new file mode 100644
index 0000000000..1d0cf74bae
--- /dev/null
+++ b/integration-tests/pgp/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,11 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "safe_mode": false,
+ "transactional": false
+ }
+ },
+ "defaultSelection": true,
+ "name": "local",
+ "description": "Runs your workflows locally with the standard local Hop
workflow engine"
+}
\ No newline at end of file
diff --git a/integration-tests/pgp/project-config.json
b/integration-tests/pgp/project-config.json
new file mode 100644
index 0000000000..899c6c1092
--- /dev/null
+++ b/integration-tests/pgp/project-config.json
@@ -0,0 +1,15 @@
+{
+ "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath": "${PROJECT_HOME}",
+ "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome": true,
+ "config": {
+ "variables": [
+ {
+ "name": "HOP_LICENSE_HEADER_FILE",
+ "value": "${PROJECT_HOME}/../asf-header.txt",
+ "description": "This will automatically serialize the ASF license
header into pipelines and workflows in the integration test projects"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git
a/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesDialogTest.java
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesDialogTest.java
new file mode 100644
index 0000000000..3ac63d6083
--- /dev/null
+++
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesDialogTest.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.workflow.actions.pgpencryptfiles;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.function.Consumer;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.finders.UIThreadRunnable;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotTable;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The grid on the General tab is the only place a user picks the key to
encrypt or sign with, so
+ * every column has to end up in the field its header names. Between 2.18.0
and 2.18.1 it did not:
+ * the dialog filled the grid in column order (action, source, wildcard, user
id, destination) but
+ * read it back as user id, destination, wildcard. Opening a working action
and pressing OK was
+ * enough to move the wildcard into the User ID and the key into the
destination path, which broke
+ * signing for anyone who edited an existing workflow in the GUI. See
+ * https://github.com/apache/hop/issues/7276 and
https://github.com/apache/hop/issues/8206.
+ */
+@Tag("uitest")
+class ActionPGPEncryptFilesDialogTest extends SwtBotTestBase {
+
+ private static final Class<?> PKG = ActionPGPEncryptFiles.class;
+ private static final String DIALOG_TITLE =
+ BaseMessages.getString(PKG, "ActionPGPEncryptFiles.Title");
+
+ /** Deliberately distinguishable: a rotation between the three text columns
cannot pass. */
+ private static final String SIGN_SOURCE = "/data/outbox/invoices.csv";
+
+ private static final String SIGN_WILDCARD = ".*\\.csv$";
+ private static final String SIGN_USER_ID = "[email protected]";
+ private static final String SIGN_DESTINATION =
"/data/signed/invoices.csv.asc";
+
+ private static final String SEAL_SOURCE = "/data/outbox/payments.xml";
+ private static final String SEAL_WILDCARD = ".*\\.xml$";
+ private static final String SEAL_USER_ID = "[email protected]";
+ private static final String SEAL_DESTINATION =
"/data/sealed/payments.xml.gpg";
+
+ @Test
+ void openingAndConfirmingTheDialogLeavesEveryRowUntouched() {
+ ActionPGPEncryptFiles action = actionWithTwoRows();
+
+ openAndConfirm(action, bot -> {});
+
+ assertEquals(2, action.getPgpFiles().size(), "row count");
+
+ ActionPGPEncryptFiles.PgpFile signRow = action.getPgpFiles().get(0);
+ assertEquals(ActionPGPEncryptFiles.ActionType.SIGN,
signRow.getActionType(), "action type");
+ assertEquals(SIGN_SOURCE, signRow.getSourceFileFolder(), "source
file/folder");
+ assertEquals(SIGN_WILDCARD, signRow.getWildcard(), "wildcard");
+ assertEquals(SIGN_USER_ID, signRow.getUserId(), "user id (the key to sign
with)");
+ assertEquals(SIGN_DESTINATION, signRow.getDestinationFileFolder(),
"destination file/folder");
+
+ ActionPGPEncryptFiles.PgpFile sealRow = action.getPgpFiles().get(1);
+ assertEquals(
+ ActionPGPEncryptFiles.ActionType.SIGN_AND_ENCRYPT,
sealRow.getActionType(), "action type");
+ assertEquals(SEAL_SOURCE, sealRow.getSourceFileFolder(), "source
file/folder");
+ assertEquals(SEAL_WILDCARD, sealRow.getWildcard(), "wildcard");
+ assertEquals(SEAL_USER_ID, sealRow.getUserId(), "user id (the key to sign
with)");
+ assertEquals(SEAL_DESTINATION, sealRow.getDestinationFileFolder(),
"destination file/folder");
+ }
+
+ @Test
+ void everyGridColumnIsStoredInTheFieldItsHeaderNames() {
+ ActionPGPEncryptFiles action = actionWithTwoRows();
+
+ openAndConfirm(
+ action,
+ bot -> {
+ SWTBotTable grid = bot.table();
+ List<String> headers = grid.columns();
+
+ // Type a marker naming the column into the first row, under the
header itself, so the
+ // assertions below read as "what the user typed under 'User ID' is
the user id".
+ typeInto(grid, 0, headers.indexOf(label("SourceFileFolder")),
"typed-under-source");
+ typeInto(grid, 0, headers.indexOf(label("Wildcard")),
"typed-under-wildcard");
+ typeInto(grid, 0, headers.indexOf(label("UserID")),
"typed-under-user-id");
+ typeInto(grid, 0, headers.indexOf(label("DestinationFileFolder")),
"typed-under-dest");
+ });
+
+ ActionPGPEncryptFiles.PgpFile row = action.getPgpFiles().get(0);
+ assertEquals("typed-under-source", row.getSourceFileFolder(),
label("SourceFileFolder"));
+ assertEquals("typed-under-wildcard", row.getWildcard(), label("Wildcard"));
+ assertEquals("typed-under-user-id", row.getUserId(), label("UserID"));
+ assertEquals(
+ "typed-under-dest", row.getDestinationFileFolder(),
label("DestinationFileFolder"));
+ }
+
+ /** Opens the dialog, runs {@code interactions} against it, then presses OK.
*/
+ private void openAndConfirm(ActionPGPEncryptFiles action, Consumer<SWTBot>
interactions) {
+ WorkflowMeta workflowMeta = new WorkflowMeta();
+ withDialog(
+ parent ->
+ new ActionPGPEncryptFilesDialog(parent, action, workflowMeta, new
Variables()).open(),
+ bot -> {
+ SWTBot dialog = bot.shell(DIALOG_TITLE).activate().bot();
+ interactions.accept(dialog);
+ dialog.button(buttonLabel("System.Button.OK")).click();
+ });
+ }
+
+ /**
+ * Writes a cell the way the dialog itself fills the grid. SWTBot drives the
in-place editors of a
+ * plain SWT table; Hop's TableView builds its own, so the value goes
straight onto the item.
+ */
+ private static void typeInto(SWTBotTable grid, int row, int column, String
value) {
+ Table table = grid.widget;
+ UIThreadRunnable.syncExec(() -> table.getItem(row).setText(column, value));
+ }
+
+ private static String label(String field) {
+ return BaseMessages.getString(PKG, "ActionPGPEncryptFiles.Fields." + field
+ ".Label");
+ }
+
+ private static ActionPGPEncryptFiles actionWithTwoRows() {
+ ActionPGPEncryptFiles action = new ActionPGPEncryptFiles("PGP encrypt
files");
+ action
+ .getPgpFiles()
+ .add(
+ pgpFile(
+ ActionPGPEncryptFiles.ActionType.SIGN,
+ SIGN_SOURCE,
+ SIGN_WILDCARD,
+ SIGN_USER_ID,
+ SIGN_DESTINATION));
+ action
+ .getPgpFiles()
+ .add(
+ pgpFile(
+ ActionPGPEncryptFiles.ActionType.SIGN_AND_ENCRYPT,
+ SEAL_SOURCE,
+ SEAL_WILDCARD,
+ SEAL_USER_ID,
+ SEAL_DESTINATION));
+ return action;
+ }
+
+ private static ActionPGPEncryptFiles.PgpFile pgpFile(
+ ActionPGPEncryptFiles.ActionType actionType,
+ String source,
+ String wildcard,
+ String userId,
+ String destination) {
+ ActionPGPEncryptFiles.PgpFile file = new ActionPGPEncryptFiles.PgpFile();
+ file.setActionType(actionType);
+ file.setSourceFileFolder(source);
+ file.setWildcard(wildcard);
+ file.setUserId(userId);
+ file.setDestinationFileFolder(destination);
+ return file;
+ }
+}
diff --git
a/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
new file mode 100644
index 0000000000..82e2a580c4
--- /dev/null
+++
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.hop.workflow.actions.pgpencryptfiles;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.encryption.TwoWayPasswordEncoderPlugin;
+import org.apache.hop.core.encryption.TwoWayPasswordEncoderPluginType;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionBase;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.apache.hop.workflow.actions.pgpdecryptfiles.ActionPGPDecryptFiles;
+import org.apache.hop.workflow.actions.pgpverify.ActionPGPVerify;
+import org.apache.hop.workflow.engine.IWorkflowEngine;
+import org.apache.hop.workflow.engines.local.LocalWorkflowEngine;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * Drives the PGP actions against a real gpg binary and a throwaway keyring,
so "signing works" is
+ * asserted end to end rather than inferred from the command string we build.
Raised by
+ * https://github.com/apache/hop/issues/8206, where a user reported signing
had been dropped.
+ *
+ * <p>Skipped when gpg is not installed. gpg is pointed at its own keyring
through a wrapper script
+ * rather than GNUPGHOME, because {@link GPG} runs the binary with the JVM's
environment and a Java
+ * process cannot change its own.
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class ActionPGPEncryptFilesSignTest {
+
+ private static final String KEY_USER_ID = "[email protected]";
+ private static final String PLAIN_TEXT = "Apache Hop signs this file.\n";
+
+ private static Path gpgBinary;
+
+ private Path sandbox;
+ private Path gnupgHome;
+ private Path work;
+ private Path gpgWrapper;
+
+ @BeforeAll
+ static void findGpg() {
+ HopLogStore.init();
+ gpgBinary = locateGpg();
+ assumeTrue(gpgBinary != null, "gpg is not on the PATH; skipping the PGP
end-to-end tests");
+ }
+
+ @BeforeEach
+ void createThrowawayKeyring() throws Exception {
+ PluginRegistry.getInstance()
+ .registerPluginClass(
+ HopTwoWayPasswordEncoder.class.getName(),
+ TwoWayPasswordEncoderPluginType.class,
+ TwoWayPasswordEncoderPlugin.class);
+ Encr.init("Hop");
+
+ sandbox = Files.createTempDirectory(shortTempRoot(), "hop-pgp");
+ gnupgHome = Files.createDirectory(sandbox.resolve("gnupg"));
+ Files.setPosixFilePermissions(gnupgHome,
PosixFilePermissions.fromString("rwx------"));
+ work = Files.createDirectory(sandbox.resolve("work"));
+
+ gpgWrapper = sandbox.resolve("gpg-in-sandbox.sh");
+ Files.writeString(
+ gpgWrapper,
+ "#!/bin/sh\nexec '" + gpgBinary + "' --homedir '" + gnupgHome + "'
\"$@\"\n",
+ StandardCharsets.UTF_8);
+ Files.setPosixFilePermissions(gpgWrapper,
PosixFilePermissions.fromString("rwx------"));
+
+ // A passphrase-less key: the encrypt action has no passphrase field, so
signing can only ever
+ // use a key gpg can unlock on its own.
+ run(
+ gpgBinary.toString(),
+ "--homedir",
+ gnupgHome.toString(),
+ "--batch",
+ "--yes",
+ "--pinentry-mode",
+ "loopback",
+ "--passphrase",
+ "",
+ "--quick-generate-key",
+ "Hop PGP Test <" + KEY_USER_ID + ">",
+ "default",
+ "default",
+ "never");
+ }
+
+ @AfterEach
+ void removeThrowawayKeyring() throws Exception {
+ if (gnupgHome != null && Files.exists(gnupgHome)) {
+ // gpg 2 starts an agent per home directory; leave none behind holding
the folder open.
+ run("gpgconf", "--homedir", gnupgHome.toString(), "--kill", "all");
+ }
+ deleteRecursively(sandbox);
+ }
+
+ @Test
+ void signedFileIsAcceptedByTheVerifyAction() throws Exception {
+ Path source = Files.writeString(work.resolve("invoices.csv"), PLAIN_TEXT);
+ Path signed = work.resolve("invoices.csv.asc");
+
+ ActionPGPEncryptFiles sign = encryptAction();
+ sign.setAsciiMode(true);
+ sign.getPgpFiles()
+ .add(
+ pgpFile(
+ ActionPGPEncryptFiles.ActionType.SIGN,
+ source,
+ signed,
+ // The key to sign with cannot be chosen: the User ID goes to
gpg as -r, which
+ // --clearsign ignores. See
https://github.com/apache/hop/issues/8206.
+ ""));
+
+ Result result = sign.execute(new Result(), 0);
+
+ assertEquals(0, result.getNrErrors(), "signing must not report errors");
+ assertTrue(result.getResult(), "signing must succeed");
+ assertTrue(Files.exists(signed), "the signed file must be written");
+ assertTrue(
+ Files.readString(signed).startsWith("-----BEGIN PGP SIGNED
MESSAGE-----"),
+ "the output must be a clear-signed message");
+
+ ActionPGPVerify verify = new ActionPGPVerify();
+ attachToWorkflow(verify);
+ verify.setGpgLocation(gpgWrapper.toString());
+ verify.setFilename(signed.toString());
+
+ Result verified = verify.execute(new Result(), 0);
+
+ assertEquals(0, verified.getNrErrors(), "verifying the signature must not
report errors");
+ assertTrue(verified.getResult(), "the signature must verify against the
signing key");
+ }
+
+ @Test
+ void signedAndEncryptedFileSurvivesTheDecryptAction() throws Exception {
+ Path source = Files.writeString(work.resolve("payments.xml"), PLAIN_TEXT);
+ Path sealed = work.resolve("payments.xml.asc");
+ Path opened = work.resolve("payments-opened.xml");
+
+ ActionPGPEncryptFiles seal = encryptAction();
+ seal.setAsciiMode(true);
+ seal.getPgpFiles()
+ .add(
+ pgpFile(
+ ActionPGPEncryptFiles.ActionType.SIGN_AND_ENCRYPT, source,
sealed, KEY_USER_ID));
+
+ Result sealedResult = seal.execute(new Result(), 0);
+
+ assertEquals(0, sealedResult.getNrErrors(), "sign and encrypt must not
report errors");
+ assertTrue(sealedResult.getResult(), "sign and encrypt must succeed");
+ assertTrue(
+ Files.readString(sealed).startsWith("-----BEGIN PGP MESSAGE-----"),
+ "the output must be an encrypted message");
+
+ ActionPGPDecryptFiles decrypt = new ActionPGPDecryptFiles();
+ attachToWorkflow(decrypt);
+ decrypt.setGpgLocation(gpgWrapper.toString());
+ decrypt.setDestinationIsAFile(true);
+ ActionPGPDecryptFiles.FileToDecrypt toDecrypt = new
ActionPGPDecryptFiles.FileToDecrypt();
+ toDecrypt.setSourceFileFolder(sealed.toString());
+ toDecrypt.setDestinationFileFolder(opened.toString());
+ toDecrypt.setPassphrase("");
+ decrypt.setFilesToDecrypt(List.of(toDecrypt));
+
+ Result openedResult = decrypt.execute(new Result(), 0);
+
+ assertEquals(0, openedResult.getNrErrors(), "decrypting must not report
errors");
+ assertTrue(openedResult.getResult(), "decrypting must succeed");
+ assertEquals(PLAIN_TEXT, Files.readString(opened), "the round trip must
preserve the content");
+ }
+
+ private ActionPGPEncryptFiles encryptAction() {
+ ActionPGPEncryptFiles action = new ActionPGPEncryptFiles("PGP encrypt
files");
+ attachToWorkflow(action);
+ action.setGpgLocation(gpgWrapper.toString());
+ action.setDestinationIsAFile(true);
+ return action;
+ }
+
+ private static ActionPGPEncryptFiles.PgpFile pgpFile(
+ ActionPGPEncryptFiles.ActionType actionType, Path source, Path
destination, String userId) {
+ ActionPGPEncryptFiles.PgpFile file = new ActionPGPEncryptFiles.PgpFile();
+ file.setActionType(actionType);
+ file.setSourceFileFolder(source.toString());
+ file.setDestinationFileFolder(destination.toString());
+ file.setUserId(userId);
+ return file;
+ }
+
+ private static void attachToWorkflow(ActionBase action) {
+ IWorkflowEngine<WorkflowMeta> workflow = new LocalWorkflowEngine(new
WorkflowMeta());
+ workflow.getWorkflowMeta().addAction(new ActionMeta(action));
+ workflow.setStopped(false);
+ action.setParentWorkflow(workflow);
+ action.setParentWorkflowMeta(mock(WorkflowMeta.class));
+ }
+
+ /**
+ * gpg-agent listens on a unix socket inside its home directory, and those
paths are capped near
+ * 100 characters. macOS hands out temp folders long enough to blow that on
their own, so keep the
+ * keyring under /tmp where one is available.
+ */
+ private static Path shortTempRoot() {
+ Path tmp = Path.of("/tmp");
+ if (Files.isDirectory(tmp) && Files.isWritable(tmp)) {
+ return tmp;
+ }
+ return Path.of(System.getProperty("java.io.tmpdir"));
+ }
+
+ private static Path locateGpg() {
+ String path = System.getenv("PATH");
+ if (path == null) {
+ return null;
+ }
+ for (String folder : path.split(java.io.File.pathSeparator)) {
+ Path candidate = Path.of(folder, "gpg");
+ if (Files.isExecutable(candidate)) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ private static void run(String... command) throws Exception {
+ Process process = new
ProcessBuilder(command).redirectErrorStream(true).start();
+ String output = new String(process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
+ assertTrue(process.waitFor(60, TimeUnit.SECONDS), "timed out: " +
String.join(" ", command));
+ assertEquals(0, process.exitValue(), String.join(" ", command) + "
failed:\n" + output);
+ }
+
+ private static void deleteRecursively(Path root) throws IOException {
+ if (root == null || !Files.exists(root)) {
+ return;
+ }
+ try (var paths = Files.walk(root)) {
+ paths.sorted(Comparator.reverseOrder()).forEach(p ->
p.toFile().delete());
+ }
+ }
+}