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 c54a59c045 Fix put ftp does not handle password error #5894 (#5900)
c54a59c045 is described below

commit c54a59c045851fe49956068278cd0d167c840f3c
Author: lance <[email protected]>
AuthorDate: Mon Nov 17 20:43:55 2025 +0800

    Fix put ftp does not handle password error #5894 (#5900)
    
    * fix put ftp does not handle password error
    
    Signed-off-by: lance <[email protected]>
    
    * Add integration test
    
    ---------
    
    Signed-off-by: lance <[email protected]>
    Co-authored-by: Hans Van Akelyen <[email protected]>
---
 .../integration-tests/integration-tests-ftp.yaml   |  34 +
 integration-tests/ftp/dev-env-config.json          |  19 +
 integration-tests/ftp/files/test.txt               |   1 +
 integration-tests/ftp/hop-config.json              | 290 +++++++
 integration-tests/ftp/main-001-ftp-put.hwf         |  96 +++
 .../metadata/pipeline-run-configuration/local.json |  22 +
 .../metadata/workflow-run-configuration/local.json |  11 +
 integration-tests/ftp/project-config.json          |  13 +
 plugins/actions/ftp/pom.xml                        |   2 +-
 .../hop/workflow/actions/ftpput/ActionFtpPut.java  | 842 +++++++--------------
 .../actions/ftpput/ActionFtpPutDialog.java         |  27 +-
 .../hop/workflow/actions/util/FtpHelper.java       |  56 ++
 .../workflow/actions/ftpput/ActionFtpPutTests.java | 237 ++++++
 .../ftpput/WorkflowActionFtpPutLoadSaveTest.java   |   2 +-
 .../hop/workflow/actions/util/FtpHelperTests.java  |  91 +++
 .../transforms/addsequence/AddSequenceMeta.java    |  32 +-
 .../addsequence/AddSequenceMetaTest.java           |   6 +-
 .../addsnowflakeid/AddSnowflakeIdData.java         |   1 +
 .../addsnowflakeid/SnowflakeSafeIdGenerator.java   |   2 +
 19 files changed, 1179 insertions(+), 605 deletions(-)

diff --git a/docker/integration-tests/integration-tests-ftp.yaml 
b/docker/integration-tests/integration-tests-ftp.yaml
new file mode 100644
index 0000000000..18191884f3
--- /dev/null
+++ b/docker/integration-tests/integration-tests-ftp.yaml
@@ -0,0 +1,34 @@
+# 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.
+
+services:
+  integration_test_xml:
+    extends:
+      file: integration-tests-base.yaml
+      service: integration_test
+    links:
+      - ftp-server
+
+  ftp-server:
+    image: "delfer/alpine-ftp-server:latest"
+    hostname: ftp-server
+    ports:
+      - "21:21"
+      - "21000-21010:21000-21010"
+    environment:
+      - "USERS=admin|admin"
+      - "ADDRESS=ftp-server"
diff --git a/integration-tests/ftp/dev-env-config.json 
b/integration-tests/ftp/dev-env-config.json
new file mode 100644
index 0000000000..08f3788e4d
--- /dev/null
+++ b/integration-tests/ftp/dev-env-config.json
@@ -0,0 +1,19 @@
+{
+  "variables" : [
+    {
+      "name" : "FTP_SERVER",
+      "value" : "ftp-server",
+      "description" : ""
+    },
+    {
+      "name" : "FTP_USER",
+      "value" : "admin",
+      "description" : ""
+    },
+    {
+      "name" : "FTP_PASSWORD",
+      "value" : "admin",
+      "description" : ""
+    }
+  ]
+}
\ No newline at end of file
diff --git a/integration-tests/ftp/files/test.txt 
b/integration-tests/ftp/files/test.txt
new file mode 100644
index 0000000000..9f4b6d8bfe
--- /dev/null
+++ b/integration-tests/ftp/files/test.txt
@@ -0,0 +1 @@
+This is a test file
diff --git a/integration-tests/ftp/hop-config.json 
b/integration-tests/ftp/hop-config.json
new file mode 100644
index 0000000000..102ac981d5
--- /dev/null
+++ b/integration-tests/ftp/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": true,
+    "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/ftp/main-001-ftp-put.hwf 
b/integration-tests/ftp/main-001-ftp-put.hwf
new file mode 100644
index 0000000000..36601d3a14
--- /dev/null
+++ b/integration-tests/ftp/main-001-ftp-put.hwf
@@ -0,0 +1,96 @@
+<?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-001-ftp-put</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description/>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2025/11/17 12:35:16.622</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2025/11/17 12:35:16.622</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>50</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Put a file with FTP</name>
+      <description/>
+      <type>FTP_PUT</type>
+      <attributes/>
+      <servername>${FTP_SERVER}</servername>
+      <serverport>21</serverport>
+      <username>${FTP_USER}</username>
+      <password>${FTP_PASSWORD}</password>
+      <remoteDirectory>/admin</remoteDirectory>
+      <localDirectory>${PROJECT_HOME}/files</localDirectory>
+      <wildcard>.*.txt</wildcard>
+      <binary>N</binary>
+      <timeout>0</timeout>
+      <remove>N</remove>
+      <only_new>N</only_new>
+      <active>N</active>
+      <control_encoding>ISO-8859-1</control_encoding>
+      <proxy_host/>
+      <proxy_port/>
+      <proxy_username/>
+      <proxy_password>Encrypted </proxy_password>
+      <socksproxy_host/>
+      <socksproxy_port>1080</socksproxy_port>
+      <socksproxy_username/>
+      <socksproxy_password>Encrypted </socksproxy_password>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>Put a file with FTP</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/ftp/metadata/pipeline-run-configuration/local.json 
b/integration-tests/ftp/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 0000000000..0c0d87c84d
--- /dev/null
+++ b/integration-tests/ftp/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,22 @@
+{
+  "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": "",
+  "dataProfile": "",
+  "executionInfoLocationName": ""
+}
\ No newline at end of file
diff --git 
a/integration-tests/ftp/metadata/workflow-run-configuration/local.json 
b/integration-tests/ftp/metadata/workflow-run-configuration/local.json
new file mode 100644
index 0000000000..1d0cf74bae
--- /dev/null
+++ b/integration-tests/ftp/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/ftp/project-config.json 
b/integration-tests/ftp/project-config.json
new file mode 100644
index 0000000000..6a91171e1c
--- /dev/null
+++ b/integration-tests/ftp/project-config.json
@@ -0,0 +1,13 @@
+{
+  "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/ftp/pom.xml b/plugins/actions/ftp/pom.xml
index 948b5d4fc6..8989cd10bc 100644
--- a/plugins/actions/ftp/pom.xml
+++ b/plugins/actions/ftp/pom.xml
@@ -31,7 +31,7 @@
     <name>Hop Plugins Actions Get/Put/Delete a file with FTP/SFTP</name>
 
     <properties>
-        <commons-net-version>3.9.0</commons-net-version>
+        <commons-net-version>3.12.0</commons-net-version>
     </properties>
 
     <dependencyManagement>
diff --git 
a/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPut.java
 
b/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPut.java
index 11dd1269d0..0cb5a7a314 100644
--- 
a/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPut.java
+++ 
b/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPut.java
@@ -18,15 +18,24 @@
 package org.apache.hop.workflow.actions.ftpput;
 
 import java.io.File;
+import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
-import java.util.ArrayList;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.regex.Matcher;
+import java.util.Map;
 import java.util.regex.Pattern;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.commons.net.ftp.FTP;
 import org.apache.commons.net.ftp.FTPClient;
+import org.apache.commons.net.ftp.FTPReply;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.Result;
@@ -40,8 +49,6 @@ import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
-import org.apache.hop.resource.ResourceEntry;
-import org.apache.hop.resource.ResourceEntry.ResourceType;
 import org.apache.hop.resource.ResourceReference;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.ActionBase;
@@ -49,10 +56,13 @@ import org.apache.hop.workflow.action.IAction;
 import org.apache.hop.workflow.action.validator.ActionValidatorUtils;
 import org.apache.hop.workflow.action.validator.AndValidator;
 import org.apache.hop.workflow.actions.util.FtpClientUtil;
+import org.apache.hop.workflow.actions.util.FtpHelper;
 import org.apache.hop.workflow.actions.util.IFtpConnection;
 import org.w3c.dom.Node;
 
 /** This defines an FTP put action. */
+@Getter
+@Setter
 @Action(
     id = "FTP_PUT",
     name = "i18n::ActionFTPPut.Name",
@@ -61,13 +71,15 @@ import org.w3c.dom.Node;
     categoryDescription = 
"i18n:org.apache.hop.workflow:ActionCategory.Category.FileTransfer",
     keywords = "i18n::ActionFtpPut.keyword",
     documentationUrl = "/workflow/actions/ftpput.html")
+@EqualsAndHashCode(callSuper = false)
 public class ActionFtpPut extends ActionBase implements Cloneable, IAction, 
IFtpConnection {
   private static final Class<?> PKG = ActionFtpPut.class;
   private static final String CONST_SPACE_SHORT = "      ";
   private static final String CONST_PASSWORD = "password";
   private static final String CONST_LOCAL_DIRECTORY = "localDirectory";
 
-  public static final int FTP_DEFAULT_PORT = 21;
+  public static final String FTP_DEFAULT_PORT = "21";
+  public static final String FTP_DEFAULT_PROXY_PORT = "1080";
 
   private String serverName;
   private String serverPort;
@@ -79,17 +91,16 @@ public class ActionFtpPut extends ActionBase implements 
Cloneable, IAction, IFtp
   private boolean binaryMode;
   private int timeout;
   private boolean remove;
-  private boolean onlyPuttingNewFiles; /* Don't overwrite files */
+  /* Don't overwrite files */
+  private boolean onlyPuttingNewFiles;
   private boolean activeConnection;
-  private String controlEncoding; /* how to convert list of filenames e.g. */
+  /* how to convert list of filenames e.g. */
+  private String controlEncoding;
   private String proxyHost;
-
-  private String proxyPort; /* string to allow variable substitution */
-
+  /* string to allow variable substitution */
+  private String proxyPort;
   private String proxyUsername;
-
   private String proxyPassword;
-
   private String socksProxyHost;
   private String socksProxyPort;
   private String socksProxyUsername;
@@ -104,8 +115,8 @@ public class ActionFtpPut extends ActionBase implements 
Cloneable, IAction, IFtp
   public ActionFtpPut(String n) {
     super(n, "");
     serverName = null;
-    serverPort = "21";
-    socksProxyPort = "1080";
+    serverPort = FTP_DEFAULT_PORT;
+    socksProxyPort = FTP_DEFAULT_PROXY_PORT;
     remoteDirectory = null;
     localDirectory = null;
     setControlEncoding(DEFAULT_CONTROL_ENCODING);
@@ -115,55 +126,35 @@ public class ActionFtpPut extends ActionBase implements 
Cloneable, IAction, IFtp
     this("");
   }
 
-  @Override
-  public Object clone() {
-    ActionFtpPut je = (ActionFtpPut) super.clone();
-    return je;
-  }
-
   @Override
   public String getXml() {
-    StringBuilder xml = new StringBuilder(450); // 365 characters in spaces 
and tag names alone
-
+    Map<String, String> tags = new LinkedHashMap<>();
+    tags.put("servername", serverName);
+    tags.put("serverport", serverPort);
+    tags.put("username", userName);
+    tags.put(CONST_PASSWORD, 
Encr.encryptPasswordIfNotUsingVariables(getPassword()));
+    tags.put("remoteDirectory", remoteDirectory);
+    tags.put(CONST_LOCAL_DIRECTORY, localDirectory);
+    tags.put("wildcard", wildcard);
+    tags.put("binary", binaryMode ? "Y" : "N");
+    tags.put("timeout", String.valueOf(timeout));
+    tags.put("remove", remove ? "Y" : "N");
+    tags.put("only_new", onlyPuttingNewFiles ? "Y" : "N");
+    tags.put("active", activeConnection ? "Y" : "N");
+    tags.put("control_encoding", controlEncoding);
+    tags.put("proxy_host", proxyHost);
+    tags.put("proxy_port", proxyPort);
+    tags.put("proxy_username", proxyUsername);
+    tags.put("proxy_password", 
Encr.encryptPasswordIfNotUsingVariables(proxyPassword));
+    tags.put("socksproxy_host", socksProxyHost);
+    tags.put("socksproxy_port", socksProxyPort);
+    tags.put("socksproxy_username", socksProxyUsername);
+    tags.put("socksproxy_password", 
Encr.encryptPasswordIfNotUsingVariables(socksProxyPassword));
+
+    // 365 characters in spaces and tag names alone
+    StringBuilder xml = new StringBuilder(450);
     xml.append(super.getXml());
-
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("servername", 
serverName));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("serverport", 
serverPort));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("username", 
userName));
-    xml.append(CONST_SPACE_SHORT)
-        .append(
-            XmlHandler.addTagValue(
-                CONST_PASSWORD, 
Encr.encryptPasswordIfNotUsingVariables(getPassword())));
-    xml.append(CONST_SPACE_SHORT)
-        .append(XmlHandler.addTagValue("remoteDirectory", remoteDirectory));
-    xml.append(CONST_SPACE_SHORT)
-        .append(XmlHandler.addTagValue(CONST_LOCAL_DIRECTORY, localDirectory));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("wildcard", 
wildcard));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("binary", 
binaryMode));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("timeout", 
timeout));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("remove", 
remove));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("only_new", 
onlyPuttingNewFiles));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("active", 
activeConnection));
-    xml.append(CONST_SPACE_SHORT)
-        .append(XmlHandler.addTagValue("control_encoding", controlEncoding));
-
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("proxy_host", 
proxyHost));
-    xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("proxy_port", 
proxyPort));
-    
xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("proxy_username", 
proxyUsername));
-    xml.append(CONST_SPACE_SHORT)
-        .append(
-            XmlHandler.addTagValue(
-                "proxy_password", 
Encr.encryptPasswordIfNotUsingVariables(proxyPassword)));
-    
xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("socksproxy_host", 
socksProxyHost));
-    
xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue("socksproxy_port", 
socksProxyPort));
-    xml.append(CONST_SPACE_SHORT)
-        .append(XmlHandler.addTagValue("socksproxy_username", 
socksProxyUsername));
-    xml.append(CONST_SPACE_SHORT)
-        .append(
-            XmlHandler.addTagValue(
-                "socksproxy_password",
-                Encr.encryptPasswordIfNotUsingVariables(socksProxyPassword)));
-
+    tags.forEach((k, v) -> 
xml.append(CONST_SPACE_SHORT).append(XmlHandler.addTagValue(k, v)));
     return xml.toString();
   }
 
@@ -172,528 +163,84 @@ public class ActionFtpPut extends ActionBase implements 
Cloneable, IAction, IFtp
       throws HopXmlException {
     try {
       super.loadXml(entrynode);
-      serverName = XmlHandler.getTagValue(entrynode, "servername");
-      serverPort = XmlHandler.getTagValue(entrynode, "serverport");
-      userName = XmlHandler.getTagValue(entrynode, "username");
-      password =
-          Encr.decryptPasswordOptionallyEncrypted(
-              XmlHandler.getTagValue(entrynode, CONST_PASSWORD));
-      remoteDirectory = XmlHandler.getTagValue(entrynode, "remoteDirectory");
-      localDirectory = XmlHandler.getTagValue(entrynode, 
CONST_LOCAL_DIRECTORY);
-      wildcard = XmlHandler.getTagValue(entrynode, "wildcard");
-      binaryMode = "Y".equalsIgnoreCase(XmlHandler.getTagValue(entrynode, 
"binary"));
-      timeout = Const.toInt(XmlHandler.getTagValue(entrynode, "timeout"), 
10000);
-      remove = "Y".equalsIgnoreCase(XmlHandler.getTagValue(entrynode, 
"remove"));
-      onlyPuttingNewFiles = 
"Y".equalsIgnoreCase(XmlHandler.getTagValue(entrynode, "only_new"));
-      activeConnection = 
"Y".equalsIgnoreCase(XmlHandler.getTagValue(entrynode, "active"));
-      controlEncoding = XmlHandler.getTagValue(entrynode, "control_encoding");
-
-      proxyHost = XmlHandler.getTagValue(entrynode, "proxy_host");
-      proxyPort = XmlHandler.getTagValue(entrynode, "proxy_port");
-      proxyUsername = XmlHandler.getTagValue(entrynode, "proxy_username");
-      proxyPassword =
-          Encr.decryptPasswordOptionallyEncrypted(
-              XmlHandler.getTagValue(entrynode, "proxy_password"));
-      socksProxyHost = XmlHandler.getTagValue(entrynode, "socksproxy_host");
-      socksProxyPort = XmlHandler.getTagValue(entrynode, "socksproxy_port");
-      socksProxyUsername = XmlHandler.getTagValue(entrynode, 
"socksproxy_username");
-      socksProxyPassword =
-          Encr.decryptPasswordOptionallyEncrypted(
-              XmlHandler.getTagValue(entrynode, "socksproxy_password"));
-
-      if (controlEncoding == null) {
+
+      serverName = extractString(entrynode, "servername");
+      serverPort = extractString(entrynode, "serverport");
+      userName = extractString(entrynode, "username");
+      password = extractDecrypted(entrynode, CONST_PASSWORD);
+      remoteDirectory = extractString(entrynode, "remoteDirectory");
+      localDirectory = extractString(entrynode, CONST_LOCAL_DIRECTORY);
+      wildcard = extractString(entrynode, "wildcard");
+      binaryMode = extractBoolean(entrynode, "binary");
+      timeout = extractTimeout(entrynode);
+      remove = extractBoolean(entrynode, "remove");
+      onlyPuttingNewFiles = extractBoolean(entrynode, "only_new");
+      activeConnection = extractBoolean(entrynode, "active");
+      controlEncoding = extractString(entrynode, "control_encoding");
+      proxyHost = extractString(entrynode, "proxy_host");
+      proxyPort = extractString(entrynode, "proxy_port");
+      proxyUsername = extractString(entrynode, "proxy_username");
+      proxyPassword = extractDecrypted(entrynode, "proxy_password");
+      socksProxyHost = extractString(entrynode, "socksproxy_host");
+      socksProxyPort = extractString(entrynode, "socksproxy_port");
+      socksProxyUsername = extractString(entrynode, "socksproxy_username");
+      socksProxyPassword = extractDecrypted(entrynode, "socksproxy_password");
+
+      if (Utils.isEmpty(controlEncoding)) {
         // if we couldn't retrieve an encoding, assume it's an old instance and
         // put in the the encoding used before v 2.4.0
         controlEncoding = LEGACY_CONTROL_ENCODING;
       }
-    } catch (HopXmlException xe) {
+    } catch (HopXmlException ex) {
       throw new HopXmlException(
-          BaseMessages.getString(PKG, "ActionFtpPut.Log.UnableToLoadFromXml"), 
xe);
+          BaseMessages.getString(PKG, "ActionFtpPut.Log.UnableToLoadFromXml"), 
ex);
     }
   }
 
-  /**
-   * @return Returns the binaryMode.
-   */
-  @Override
-  public boolean isBinaryMode() {
-    return binaryMode;
-  }
-
-  /**
-   * @param binaryMode The binaryMode to set.
-   */
-  public void setBinaryMode(boolean binaryMode) {
-    this.binaryMode = binaryMode;
-  }
-
-  /**
-   * @param timeout The timeout to set.
-   */
-  public void setTimeout(int timeout) {
-    this.timeout = timeout;
-  }
-
-  /**
-   * @return Returns the timeout.
-   */
-  @Override
-  public int getTimeout() {
-    return timeout;
-  }
-
-  /**
-   * @return Returns the onlyGettingNewFiles.
-   */
-  public boolean isOnlyPuttingNewFiles() {
-    return onlyPuttingNewFiles;
-  }
-
-  /**
-   * @param onlyPuttingNewFiles Only transfer new files to the remote host
-   */
-  public void setOnlyPuttingNewFiles(boolean onlyPuttingNewFiles) {
-    this.onlyPuttingNewFiles = onlyPuttingNewFiles;
-  }
-
-  /**
-   * Get the control encoding to be used for ftp'ing
-   *
-   * @return the used encoding
-   */
-  @Override
-  public String getControlEncoding() {
-    return controlEncoding;
-  }
-
-  /**
-   * Set the encoding to be used for ftp'ing. This determines how names are 
translated in dir e.g.
-   * It does impact the contents of the files being ftp'ed.
-   *
-   * @param encoding The encoding to be used.
-   */
-  public void setControlEncoding(String encoding) {
-    this.controlEncoding = encoding;
-  }
-
-  /**
-   * @return Returns the remoteDirectory.
-   */
-  public String getRemoteDirectory() {
-    return remoteDirectory;
-  }
-
-  /**
-   * @param directory The remoteDirectory to set.
-   */
-  public void setRemoteDirectory(String directory) {
-    this.remoteDirectory = directory;
-  }
-
-  /**
-   * @return Returns the password.
-   */
-  @Override
-  public String getPassword() {
-    return password;
-  }
-
-  /**
-   * @param password The password to set.
-   */
-  public void setPassword(String password) {
-    this.password = password;
-  }
-
-  /**
-   * @return Returns the serverName.
-   */
-  @Override
-  public String getServerName() {
-    return serverName;
-  }
-
-  /**
-   * @param serverName The serverName to set.
-   */
-  public void setServerName(String serverName) {
-    this.serverName = serverName;
-  }
-
-  /**
-   * @return Returns the userName.
-   */
-  @Override
-  public String getUserName() {
-    return userName;
-  }
-
-  /**
-   * @param userName The userName to set.
-   */
-  public void setUserName(String userName) {
-    this.userName = userName;
-  }
-
-  /**
-   * @return Returns the wildcard.
-   */
-  public String getWildcard() {
-    return wildcard;
-  }
-
-  /**
-   * @param wildcard The wildcard to set.
-   */
-  public void setWildcard(String wildcard) {
-    this.wildcard = wildcard;
-  }
-
-  /**
-   * @return Returns the localDirectory.
-   */
-  public String getLocalDirectory() {
-    return localDirectory;
-  }
-
-  /**
-   * @param directory The localDirectory to set.
-   */
-  public void setLocalDirectory(String directory) {
-    this.localDirectory = directory;
-  }
-
-  /**
-   * @param remove The remove to set.
-   */
-  public void setRemove(boolean remove) {
-    this.remove = remove;
-  }
-
-  /**
-   * @return Returns the remove.
-   */
-  public boolean getRemove() {
-    return remove;
-  }
-
-  @Override
-  public String getServerPort() {
-    return serverPort;
-  }
-
-  public void setServerPort(String serverPort) {
-    this.serverPort = serverPort;
-  }
-
-  /**
-   * @return the activeConnection
-   */
-  @Override
-  public boolean isActiveConnection() {
-    return activeConnection;
-  }
-
-  /**
-   * @param activeConnection set to true to get an active FTP connection
-   */
-  public void setActiveConnection(boolean activeConnection) {
-    this.activeConnection = activeConnection;
-  }
-
-  /**
-   * @return Returns the hostname of the ftp-proxy.
-   */
-  @Override
-  public String getProxyHost() {
-    return proxyHost;
-  }
-
-  /**
-   * @param proxyHost The hostname of the proxy.
-   */
-  public void setProxyHost(String proxyHost) {
-    this.proxyHost = proxyHost;
-  }
-
-  /**
-   * @return Returns the password which is used to authenticate at the proxy.
-   */
-  @Override
-  public String getProxyPassword() {
-    return proxyPassword;
-  }
-
-  /**
-   * @param proxyPassword The password which is used to authenticate at the 
proxy.
-   */
-  public void setProxyPassword(String proxyPassword) {
-    this.proxyPassword = proxyPassword;
-  }
-
-  /**
-   * @return Returns the port of the ftp-proxy.
-   */
-  @Override
-  public String getProxyPort() {
-    return proxyPort;
-  }
-
-  /**
-   * @param proxyPort The port of the ftp-proxy.
-   */
-  public void setProxyPort(String proxyPort) {
-    this.proxyPort = proxyPort;
-  }
-
-  /**
-   * @return Returns the username which is used to authenticate at the proxy.
-   */
-  @Override
-  public String getProxyUsername() {
-    return proxyUsername;
-  }
-
-  /**
-   * @param socksProxyHost The socks proxy host to set
-   */
-  public void setSocksProxyHost(String socksProxyHost) {
-    this.socksProxyHost = socksProxyHost;
-  }
-
-  /**
-   * @param socksProxyPort The socks proxy port to set
-   */
-  public void setSocksProxyPort(String socksProxyPort) {
-    this.socksProxyPort = socksProxyPort;
-  }
-
-  /**
-   * @param socksProxyUsername The socks proxy username to set
-   */
-  public void setSocksProxyUsername(String socksProxyUsername) {
-    this.socksProxyUsername = socksProxyUsername;
-  }
-
-  /**
-   * @param socksProxyPassword The socks proxy password to set
-   */
-  public void setSocksProxyPassword(String socksProxyPassword) {
-    this.socksProxyPassword = socksProxyPassword;
-  }
-
-  /**
-   * @return The sox proxy host name
-   */
-  @Override
-  public String getSocksProxyHost() {
-    return this.socksProxyHost;
-  }
-
-  /**
-   * @return The socks proxy port
-   */
   @Override
-  public String getSocksProxyPort() {
-    return this.socksProxyPort;
-  }
-
-  /**
-   * @return The socks proxy username
-   */
-  @Override
-  public String getSocksProxyUsername() {
-    return this.socksProxyUsername;
-  }
-
-  /**
-   * @return The socks proxy password
-   */
-  @Override
-  public String getSocksProxyPassword() {
-    return this.socksProxyPassword;
-  }
-
-  /**
-   * @param proxyUsername The username which is used to authenticate at the 
proxy.
-   */
-  public void setProxyUsername(String proxyUsername) {
-    this.proxyUsername = proxyUsername;
+  @SuppressWarnings("java:S2975")
+  public Object clone() {
+    return super.clone();
   }
 
   @Override
-  public Result execute(Result previousResult, int nr) {
-    Result result = previousResult;
-    result.setResult(false);
+  public Result execute(Result prevResult, int nr) throws HopException {
+    prevResult.setResult(false);
     long filesPut = 0;
 
     if (isDetailed()) {
       logDetailed(BaseMessages.getString(PKG, "ActionFtpPut.Log.Starting"));
     }
 
-    FTPClient ftpclient = null;
+    FTPClient ftpClient = null;
     try {
-      // Create ftp client to host:port ...
-      ftpclient = createAndSetUpFtpClient();
-
-      // move to spool dir ...
-      String realRemoteDirectory = resolve(remoteDirectory);
-      if (!Utils.isEmpty(realRemoteDirectory)) {
-        ftpclient.changeWorkingDirectory(realRemoteDirectory);
-        if (isDetailed()) {
-          logDetailed(
-              BaseMessages.getString(
-                  PKG, "ActionFtpPut.Log.ChangedDirectory", 
realRemoteDirectory));
-        }
-      }
-
-      String realLocalDirectory = resolve(localDirectory);
-      if (realLocalDirectory == null) {
-        throw new HopException(BaseMessages.getString(PKG, 
"ActionFtpPut.LocalDir.NotSpecified"));
-      } else {
-        // handle file:/// prefix
-        if (realLocalDirectory.startsWith("file:")) {
-          realLocalDirectory = new URI(realLocalDirectory).getPath();
-        }
-      }
-
-      final List<String> files;
-      File localFiles = new File(realLocalDirectory);
-      File[] children = localFiles.listFiles();
-      if (children == null) {
-        files = Collections.emptyList();
-      } else {
-        files = new ArrayList<>(children.length);
-        for (File child : children) {
-          // Get filename of file or directory
-          if (!child.isDirectory()) {
-            files.add(child.getName());
-          }
-        }
-      }
-      if (isDetailed()) {
-        logDetailed(
-            BaseMessages.getString(
-                PKG,
-                "ActionFtpPut.Log.FoundFileLocalDirectory",
-                "" + files.size(),
-                realLocalDirectory));
-      }
-
-      String realWildcard = resolve(wildcard);
-      Pattern pattern;
-      if (!Utils.isEmpty(realWildcard)) {
-        pattern = Pattern.compile(realWildcard);
-      } else {
-        pattern = null;
-      }
+      ftpClient = prepareFtpClient();
+      changeRemoteDirectory(ftpClient);
 
+      String realLocalDirectory = resolveLocalDirectory();
+      List<String> files = listLocalFiles(realLocalDirectory);
+      Pattern pattern = createPattern(resolve(wildcard));
+      // for the files and upload file
       for (String file : files) {
         if (parentWorkflow.isStopped()) {
           break;
         }
 
-        boolean toBeProcessed = true;
-
-        // First see if the file matches the regular expression!
-        if (pattern != null) {
-          Matcher matcher = pattern.matcher(file);
-          toBeProcessed = matcher.matches();
-        }
-
-        if (toBeProcessed) {
-          // File exists?
-          boolean fileExist = false;
-          try {
-            fileExist = FtpClientUtil.fileExists(ftpclient, file);
-          } catch (Exception e) {
-            logError("Error checking for file existence on FTP server for 
file: " + file, e);
-            // Assume file does not exist !!
-          }
-
-          if (isDebug()) {
-            if (fileExist) {
-              logDebug(BaseMessages.getString(PKG, 
"ActionFtpPut.Log.FileExists", file));
-            } else {
-              logDebug(BaseMessages.getString(PKG, 
"ActionFtpPut.Log.FileDoesNotExists", file));
-            }
-          }
-
-          if (!fileExist || !onlyPuttingNewFiles) {
-            if (isDebug()) {
-              logDebug(
-                  BaseMessages.getString(
-                      PKG,
-                      "ActionFtpPut.Log.PuttingFileToRemoteDirectory",
-                      file,
-                      realRemoteDirectory));
-            }
-
-            String localFilename = realLocalDirectory + Const.FILE_SEPARATOR + 
file;
-            try (InputStream inputStream = 
HopVfs.getInputStream(localFilename)) {
-              if (fileExist) {
-                boolean deleted = ftpclient.deleteFile(file);
-                if (!deleted) {
-                  logError(
-                      "Deletion of (existing) file '"
-                          + file
-                          + "' on the FTP server was not successful with reply 
string: "
-                          + ftpclient.getReplyString());
-                }
-              }
-              if (binaryMode) {
-                ftpclient.setFileType(FTP.BINARY_FILE_TYPE);
-              }
-              boolean success = ftpclient.storeFile(file, inputStream);
-              if (success) {
-                filesPut++;
-              } else {
-                logError(
-                    "Transfer of file '"
-                        + localFilename
-                        + "' to the FTP server was not successful with reply 
string: "
-                        + ftpclient.getReplyString());
-              }
-            }
-
-            // Delete the file if this is needed!
-            if (remove) {
-              new File(localFilename).delete();
-              if (isDetailed()) {
-                logDetailed(
-                    BaseMessages.getString(PKG, 
"ActionFtpPut.Log.DeletedFile", localFilename));
-              }
-            }
-          }
+        if (shouldProcessFile(file, pattern) && uploadFile(ftpClient, 
realLocalDirectory, file)) {
+          filesPut++;
+          deleteLocalFileIfNeeded(realLocalDirectory + Const.FILE_SEPARATOR + 
file);
         }
       }
-
-      result.setResult(true);
-      if (isBasic()) {
-        logBasic(BaseMessages.getString(PKG, "ActionFtpPut.Log.WeHavePut", "" 
+ filesPut));
-      }
+      // upload success.
+      prevResult.setResult(true);
+      logBasic(BaseMessages.getString(PKG, "ActionFtpPut.Log.WeHavePut", "" + 
filesPut));
     } catch (Exception e) {
-      result.setNrErrors(1);
-      logError(BaseMessages.getString(PKG, 
"ActionFtpPut.Log.ErrorPuttingFiles", e.getMessage()));
+      prevResult.setNrErrors(1);
       logError(Const.getStackTracker(e));
     } finally {
-      if (ftpclient != null && ftpclient.isConnected()) {
-        try {
-          ftpclient.quit();
-        } catch (Exception e) {
-          logError(BaseMessages.getString(PKG, 
"ActionFtpPut.Log.ErrorQuitingFTP", e.getMessage()));
-        }
-      }
-      FtpClientUtil.clearSocksJvmSettings();
+      closeFtpClient(ftpClient);
     }
-
-    return result;
-  }
-
-  // package-local visibility for testing purposes
-  FTPClient createAndSetUpFtpClient() throws HopException {
-
-    return FtpClientUtil.connectAndLogin(getLogChannel(), this, this, 
getName());
+    return prevResult;
   }
 
   @Override
@@ -705,12 +252,8 @@ public class ActionFtpPut extends ActionBase implements 
Cloneable, IAction, IFtp
   public List<ResourceReference> getResourceDependencies(
       IVariables variables, WorkflowMeta workflowMeta) {
     List<ResourceReference> references = super.getResourceDependencies(this, 
workflowMeta);
-    if (!Utils.isEmpty(serverName)) {
-      String realServerName = resolve(serverName);
-      ResourceReference reference = new ResourceReference(this);
-      reference.getEntries().add(new ResourceEntry(realServerName, 
ResourceType.SERVER));
-      references.add(reference);
-    }
+    // add resource entity
+    FtpHelper.addServerResourceReferenceIfPresent(references, serverName, 
this, this);
     return references;
   }
 
@@ -753,4 +296,189 @@ public class ActionFtpPut extends ActionBase implements 
Cloneable, IAction, IFtp
             remarks,
             
AndValidator.putValidators(ActionValidatorUtils.integerValidator()));
   }
+
+  /**
+   * Creates the ftp client for this action.
+   *
+   * @return an initialized and connected {@link FTPClient}
+   * @throws HopException Exception if connection or setup fails
+   */
+  private FTPClient prepareFtpClient() throws HopException {
+    FTPClient ftpClient = createAndSetUpFtpClient();
+    if (ftpClient == null || !ftpClient.isConnected()) {
+      throw new HopException("Failed to connect FTP server.");
+    }
+
+    int code = ftpClient.getReplyCode();
+    String msg = ftpClient.getReplyString();
+    if (!FTPReply.isPositiveCompletion(code)) {
+      throw new HopException(
+          "FTP server refused connection. reply code: " + code + ", result: " 
+ msg);
+    }
+
+    if (isBasic()) {
+      logBasic("FTP connection success, reply code: {0}, result: {1}", code, 
msg);
+    }
+    return ftpClient;
+  }
+
+  /**
+   * Changes the current working directory on the FTP server to the resolved 
remote directory.
+   *
+   * @param ftpClient ftpClient the {@link FTPClient} instance
+   * @throws IOException Exception if changing directory fails
+   */
+  private void changeRemoteDirectory(FTPClient ftpClient) throws IOException {
+    String realRemoteDirectory = resolve(remoteDirectory);
+    if (Utils.isEmpty(realRemoteDirectory)) {
+      return;
+    }
+
+    ftpClient.changeWorkingDirectory(realRemoteDirectory);
+    if (isDetailed()) {
+      logDetailed(
+          BaseMessages.getString(PKG, "ActionFtpPut.Log.ChangedDirectory", 
realRemoteDirectory));
+    }
+  }
+
+  /**
+   * Resolves the local directory path, handling "file:" prefixes
+   *
+   * @return the resolved local directory path
+   * @throws HopException if the local directory is not specified
+   * @throws URISyntaxException if the local directory is not specified
+   */
+  private String resolveLocalDirectory() throws HopException, 
URISyntaxException {
+    String realLocalDirectory = resolve(localDirectory);
+    if (realLocalDirectory == null) {
+      throw new HopException(BaseMessages.getString(PKG, 
"ActionFtpPut.LocalDir.NotSpecified"));
+    }
+
+    if (realLocalDirectory.startsWith("file:")) {
+      realLocalDirectory = new URI(realLocalDirectory).getPath();
+    }
+    return realLocalDirectory;
+  }
+
+  /**
+   * Lists all non-directory files in the given local directory.
+   *
+   * @param localDir the directory path
+   * @return a list of file names (excluding subdirectories)
+   */
+  private List<String> listLocalFiles(String localDir) {
+    File[] children = new File(localDir).listFiles();
+    if (children == null) {
+      return Collections.emptyList();
+    }
+    return Arrays.stream(children).filter(f -> 
!f.isDirectory()).map(File::getName).toList();
+  }
+
+  /**
+   * Compiles a regex {@link Pattern} from the given wildcard string.
+   *
+   * @param wildcard the wildcard string (may be null or empty)
+   * @return a {@link Pattern} object, or null if wildcard is empty
+   */
+  private Pattern createPattern(String wildcard) {
+    return Utils.isEmpty(wildcard) ? null : Pattern.compile(wildcard);
+  }
+
+  /**
+   * Checks whether a file should be processed based on the optional regex 
pattern.
+   *
+   * @param file the file name
+   * @param pattern the compiled regex pattern (may be null)
+   * @return true if the file should be processed
+   */
+  private boolean shouldProcessFile(String file, Pattern pattern) {
+    return pattern == null || pattern.matcher(file).matches();
+  }
+
+  /**
+   * Uploads a single file to the FTP server, handling existing file deletion 
and binary mode.
+   *
+   * @param ftpClient the {@link FTPClient} instance
+   * @param localDir the local directory containing the file
+   * @param file the file name
+   * @return true if the file was uploaded successfully, false otherwise
+   */
+  private boolean uploadFile(FTPClient ftpClient, String localDir, String 
file) {
+    String localFilename = localDir + Const.FILE_SEPARATOR + file;
+    try (InputStream inputStream = HopVfs.getInputStream(localFilename)) {
+      boolean fileExist = FtpClientUtil.fileExists(ftpClient, file);
+      if (fileExist && !onlyPuttingNewFiles) {
+        ftpClient.deleteFile(file);
+      }
+
+      if (binaryMode) {
+        ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
+      }
+
+      boolean success = ftpClient.storeFile(file, inputStream);
+      if (!success) {
+        logError("Failed to upload file '" + localFilename + "' → " + 
ftpClient.getReplyString());
+      }
+      return success;
+    } catch (Exception e) {
+      logError("Error uploading file: " + localFilename, e);
+      return false;
+    }
+  }
+
+  /**
+   * Deletes the local file if the "remove" option is enabled.
+   *
+   * @param localFilename the full path of the local file to delete
+   */
+  private void deleteLocalFileIfNeeded(String localFilename) throws 
IOException {
+    if (remove) {
+      Files.deleteIfExists(Path.of(localFilename));
+      if (isDetailed()) {
+        logDetailed(BaseMessages.getString(PKG, 
"ActionFtpPut.Log.DeletedFile", localFilename));
+      }
+    }
+  }
+
+  /**
+   * Safely closes the FTP client connection.
+   *
+   * @param ftpClient the {@link FTPClient} instance to close
+   */
+  private void closeFtpClient(FTPClient ftpClient) {
+    if (ftpClient != null && ftpClient.isConnected()) {
+      try {
+        ftpClient.quit();
+      } catch (Exception e) {
+        logError(BaseMessages.getString(PKG, 
"ActionFtpPut.Log.ErrorQuitingFTP", e.getMessage()));
+      }
+    }
+
+    FtpClientUtil.clearSocksJvmSettings();
+  }
+
+  // package-local visibility for testing purposes
+  FTPClient createAndSetUpFtpClient() throws HopException {
+    return FtpClientUtil.connectAndLogin(getLogChannel(), this, this, 
getName());
+  }
+
+  /** extract boolean */
+  private boolean extractBoolean(Node node, String tagName) {
+    return "Y".equalsIgnoreCase(extractString(node, tagName));
+  }
+
+  /** extract timeout */
+  private int extractTimeout(Node node) {
+    return Const.toInt(extractString(node, "timeout"), 10000);
+  }
+
+  /** After extracting the string, decrypt it */
+  private String extractDecrypted(Node node, String tagName) {
+    return Encr.decryptPasswordOptionallyEncrypted(extractString(node, 
tagName));
+  }
+
+  /** extract string */
+  private String extractString(Node node, String tagName) {
+    return XmlHandler.getTagValue(node, tagName);
+  }
 }
diff --git 
a/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPutDialog.java
 
b/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPutDialog.java
index 75f4aa180f..6d07895487 100644
--- 
a/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPutDialog.java
+++ 
b/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPutDialog.java
@@ -24,6 +24,7 @@ import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
@@ -136,11 +137,8 @@ public class ActionFtpPutDialog extends ActionDialog {
     wName = new Text(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
     PropsUi.setLook(wName);
     wName.addModifyListener(lsMod);
-    FormData fdName = new FormData();
-    fdName.left = new FormAttachment(middle, 0);
-    fdName.top = new FormAttachment(0, margin);
-    fdName.right = new FormAttachment(100, 0);
-    wName.setLayoutData(fdName);
+    wName.setLayoutData(
+        FormDataBuilder.builder().left(middle, 0).top(0, margin).right(100, 
0).build());
 
     // The buttons at the bottom
     //
@@ -440,11 +438,12 @@ public class ActionFtpPutDialog extends ActionDialog {
         BaseMessages.getString(PKG, "ActionFtpPut.ControlEncoding.Tooltip"));
     wControlEncoding.setItems(encodings);
     PropsUi.setLook(wControlEncoding);
-    FormData fdControlEncoding = new FormData();
-    fdControlEncoding.left = new FormAttachment(middle, 0);
-    fdControlEncoding.top = new FormAttachment(wlControlEncoding, 0, 
SWT.CENTER);
-    fdControlEncoding.right = new FormAttachment(100, 0);
-    wControlEncoding.setLayoutData(fdControlEncoding);
+    wControlEncoding.setLayoutData(
+        FormDataBuilder.builder()
+            .left(middle, 0)
+            .top(wlControlEncoding, 0, SWT.CENTER)
+            .right(100, 0)
+            .build());
 
     FormData fdAdvancedSettings = new FormData();
     fdAdvancedSettings.left = new FormAttachment(0, margin);
@@ -827,11 +826,11 @@ public class ActionFtpPutDialog extends ActionDialog {
     closeFtpConnection();
   }
 
-  private void checkRemoteFolder(String remoteFoldername) {
-    if (!Utils.isEmpty(remoteFoldername) && connectToFtp(true, 
remoteFoldername)) {
+  private void checkRemoteFolder(String remoteFolderName) {
+    if (!Utils.isEmpty(remoteFolderName) && connectToFtp(true, 
remoteFolderName)) {
       MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
       mb.setMessage(
-          BaseMessages.getString(PKG, "ActionFtpPut.FolderExists.OK", 
remoteFoldername) + Const.CR);
+          BaseMessages.getString(PKG, "ActionFtpPut.FolderExists.OK", 
remoteFolderName) + Const.CR);
       mb.setText(BaseMessages.getString(PKG, 
"ActionFtpPut.FolderExists.Title.Ok"));
       mb.open();
     }
@@ -907,7 +906,7 @@ public class ActionFtpPutDialog extends ActionDialog {
     wRemoteDirectory.setText(Const.NVL(action.getRemoteDirectory(), ""));
     wLocalDirectory.setText(Const.NVL(action.getLocalDirectory(), ""));
     wWildcard.setText(Const.NVL(action.getWildcard(), ""));
-    wRemove.setSelection(action.getRemove());
+    wRemove.setSelection(action.isRemove());
     wBinaryMode.setSelection(action.isBinaryMode());
     wTimeout.setText("" + action.getTimeout());
     wOnlyNew.setSelection(action.isOnlyPuttingNewFiles());
diff --git 
a/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/util/FtpHelper.java
 
b/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/util/FtpHelper.java
new file mode 100644
index 0000000000..4d643b8cb1
--- /dev/null
+++ 
b/plugins/actions/ftp/src/main/java/org/apache/hop/workflow/actions/util/FtpHelper.java
@@ -0,0 +1,56 @@
+/*
+ * 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.util;
+
+import java.util.List;
+import lombok.experimental.UtilityClass;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.resource.IResourceHolder;
+import org.apache.hop.resource.ResourceEntry;
+import org.apache.hop.resource.ResourceReference;
+import org.apache.hop.workflow.action.ActionBase;
+
+/** ftp helper */
+@UtilityClass
+public class FtpHelper {
+
+  /**
+   * Adds a server {@link ResourceReference} to the given list if the provided 
server name is not
+   * empty.
+   *
+   * @param references references the list of {@link ResourceReference} 
objects to add to
+   * @param serverName serverName the server name to resolve and reference
+   * @param action actionBase
+   */
+  public static void addServerResourceReferenceIfPresent(
+      List<ResourceReference> references,
+      String serverName,
+      ActionBase action,
+      IResourceHolder holder) {
+    if (Utils.isEmpty(serverName)) {
+      return;
+    }
+
+    String realServerName = action.resolve(serverName);
+    ResourceReference reference = new ResourceReference(holder);
+    reference
+        .getEntries()
+        .add(new ResourceEntry(realServerName, 
ResourceEntry.ResourceType.SERVER));
+    references.add(reference);
+  }
+}
diff --git 
a/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPutTests.java
 
b/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPutTests.java
new file mode 100644
index 0000000000..ea4fb5d267
--- /dev/null
+++ 
b/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/ftpput/ActionFtpPutTests.java
@@ -0,0 +1,237 @@
+/*
+ * 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.ftpput;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.net.ftp.FTPClient;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopXmlException;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.resource.ResourceReference;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.w3c.dom.Node;
+
+/** ActionFtpPut test */
+class ActionFtpPutTests {
+  private ActionFtpPut action;
+
+  @BeforeEach
+  void setUp() throws HopException {
+    action = new ActionFtpPut("Test Put a file with FTP");
+    action.setUserName("user");
+    action.setPassword("password");
+    action.setServerName("127.0.0.1");
+    action.setName("Test name");
+    action.setRemoteDirectory("/home/user");
+    action.setLocalDirectory("/tmp");
+
+    HopEnvironment.init();
+  }
+
+  @Test
+  void testEmptyActionFtpPut() {
+    assertEquals("", action.getDescription());
+
+    ActionFtpPut ftpPut = new ActionFtpPut();
+    assertTrue(ftpPut.getDescription().isBlank());
+  }
+
+  @Test
+  void testClone() {
+    Object cloned = action.clone();
+    assertNotSame(cloned, action);
+  }
+
+  @Test
+  void testIsEvaluation() {
+    assertTrue(action.isEvaluation());
+  }
+
+  @Test
+  void testGetResourceDependencies() {
+    IVariables variables = mock(IVariables.class);
+    WorkflowMeta meta = mock(WorkflowMeta.class);
+
+    // 127.0.0.1 server
+    List<ResourceReference> references = 
action.getResourceDependencies(variables, meta);
+    assertNotNull(references);
+    assertEquals(1, references.size());
+
+    // null server
+    action.setServerName(null);
+    references = action.getResourceDependencies(variables, meta);
+    assertNotNull(references);
+    assertTrue(references.isEmpty());
+  }
+
+  @Test
+  void testCheck() {
+    List<ICheckResult> remarks = new ArrayList<>();
+    WorkflowMeta workflowMeta = mock(WorkflowMeta.class);
+    IVariables variables = mock(IVariables.class);
+    IHopMetadataProvider provider = mock(IHopMetadataProvider.class);
+
+    // server is null
+    action.setServerName(Const.EMPTY_STRING);
+    action.check(remarks, workflowMeta, variables, provider);
+
+    boolean hasError =
+        remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR);
+    assertTrue(hasError);
+  }
+
+  @Test
+  void testGetXmlAndLoadXml() throws HopXmlException {
+    String xml = action.getXml();
+    assertNotNull(xml);
+
+    xml = "<action>" + xml + "</action>";
+    Node node = XmlHandler.loadXmlString(xml, "action");
+    ActionFtpPut loadedAction = new ActionFtpPut();
+    loadedAction.loadXml(node, null, null);
+
+    assertAll(
+        () -> {
+          assertEquals(action.getServerName(), loadedAction.getServerName());
+          assertEquals(action.getServerPort(), loadedAction.getServerPort());
+          assertEquals(action.getUserName(), loadedAction.getUserName());
+          assertEquals(action.getPassword(), loadedAction.getPassword());
+          assertEquals(action.getLocalDirectory(), 
loadedAction.getLocalDirectory());
+          assertEquals(action.getRemoteDirectory(), 
loadedAction.getRemoteDirectory());
+          assertEquals(action.getWildcard(), loadedAction.getWildcard());
+          assertEquals(action.isBinaryMode(), loadedAction.isBinaryMode());
+          assertEquals(action.getTimeout(), loadedAction.getTimeout());
+          assertEquals(action.isRemove(), loadedAction.isRemove());
+          assertEquals(action.isOnlyPuttingNewFiles(), 
loadedAction.isOnlyPuttingNewFiles());
+          assertEquals(action.isActiveConnection(), 
loadedAction.isActiveConnection());
+          assertEquals(action.getControlEncoding(), 
loadedAction.getControlEncoding());
+          assertEquals(action.getProxyHost(), loadedAction.getProxyHost());
+          assertEquals(action.getProxyPort(), loadedAction.getProxyPort());
+          assertEquals(action.getProxyUsername(), 
loadedAction.getProxyUsername());
+          assertEquals(
+              action.getProxyPassword() == null ? "" : null, 
loadedAction.getProxyPassword());
+          assertEquals(action.getSocksProxyHost(), 
loadedAction.getSocksProxyHost());
+          assertEquals(action.getSocksProxyPort(), 
loadedAction.getSocksProxyPort());
+          assertEquals(action.getSocksProxyUsername(), 
loadedAction.getSocksProxyUsername());
+          assertEquals(
+              action.getSocksProxyPassword() == null ? "" : null,
+              loadedAction.getSocksProxyPassword());
+        });
+  }
+
+  @Test
+  void testExecuteSuccess() throws Exception {
+    Path tempFile = Files.createTempFile(Path.of(action.getLocalDirectory()), 
"file_", ".txt");
+
+    try (MockedStatic<HopVfs> ignored = mockStatic(HopVfs.class)) {
+      action = spy(new ActionFtpPut("Test FTP Action"));
+      action.setServerName("127.0.0.1");
+      action.setUserName("user");
+      action.setPassword("pass");
+      action.setRemoteDirectory("/remote");
+
+      // /tmp/directory
+      Path tempDir = Files.createTempDirectory("ftpTest");
+      action.setLocalDirectory(tempDir.toString());
+
+      FTPClient mockFtp = mock(FTPClient.class);
+      when(mockFtp.isConnected()).thenReturn(true);
+      when(mockFtp.storeFile(anyString(), 
any(InputStream.class))).thenReturn(true);
+      when(mockFtp.getReplyCode()).thenReturn(230);
+      when(mockFtp.getReplyString()).thenReturn("OK");
+
+      doReturn(mockFtp).when(action).createAndSetUpFtpClient();
+
+      when(HopVfs.getInputStream(anyString()))
+          .thenAnswer(invocation -> new ByteArrayInputStream("test 
content".getBytes()));
+
+      Result result = new Result();
+      result = action.execute(result, 0);
+
+      assertTrue(result.isResult(), "execute success");
+      assertEquals(0, result.getNrErrors());
+    } finally {
+      Files.deleteIfExists(tempFile);
+    }
+  }
+
+  @Test
+  void testExecuteFailure() throws Exception {
+    Path tempFile = Files.createTempFile(Path.of(action.getLocalDirectory()), 
"file_", ".txt");
+
+    try (MockedStatic<HopVfs> ignored = mockStatic(HopVfs.class)) {
+      action = spy(new ActionFtpPut("Test FTP Action"));
+      action.setServerName("127.0.0.1");
+      action.setUserName("user");
+      action.setPassword("pass");
+      action.setRemoteDirectory("/remote");
+
+      // /tmp/directory
+      Path tempDir = Files.createTempDirectory("ftpTest");
+      action.setLocalDirectory(tempDir.toString());
+
+      FTPClient mockFtp = mock(FTPClient.class);
+      when(mockFtp.isConnected()).thenReturn(true);
+      when(mockFtp.storeFile(anyString(), 
any(InputStream.class))).thenReturn(true);
+      when(mockFtp.getReplyCode()).thenReturn(530);
+      when(mockFtp.getReplyString()).thenReturn("530 Login incorrect");
+
+      doReturn(mockFtp).when(action).createAndSetUpFtpClient();
+
+      when(HopVfs.getInputStream(anyString()))
+          .thenAnswer(invocation -> new ByteArrayInputStream("test 
content".getBytes()));
+
+      Result result = new Result();
+      result = action.execute(result, 0);
+
+      assertFalse(result.isResult(), "530 Login incorrect");
+      assertEquals(1, result.getNrErrors());
+    } finally {
+      Files.deleteIfExists(tempFile);
+    }
+  }
+}
diff --git 
a/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/ftpput/WorkflowActionFtpPutLoadSaveTest.java
 
b/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/ftpput/WorkflowActionFtpPutLoadSaveTest.java
index 1bb873100d..51aa07f9af 100644
--- 
a/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/ftpput/WorkflowActionFtpPutLoadSaveTest.java
+++ 
b/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/ftpput/WorkflowActionFtpPutLoadSaveTest.java
@@ -71,7 +71,7 @@ class WorkflowActionFtpPutLoadSaveTest extends 
WorkflowActionLoadSaveTestSupport
         "wildcard", "getWildcard",
         "binary", "isBinaryMode",
         "timeout", "getTimeout",
-        "remove", "getRemove",
+        "remove", "isRemove",
         "only_new", "isOnlyPuttingNewFiles",
         "active", "isActiveConnection",
         "control_encoding", "getControlEncoding",
diff --git 
a/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/util/FtpHelperTests.java
 
b/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/util/FtpHelperTests.java
new file mode 100644
index 0000000000..3a2f1e28e4
--- /dev/null
+++ 
b/plugins/actions/ftp/src/test/java/org/apache/hop/workflow/actions/util/FtpHelperTests.java
@@ -0,0 +1,91 @@
+/*
+ * 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.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.only;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.Const;
+import org.apache.hop.resource.IResourceHolder;
+import org.apache.hop.resource.ResourceEntry;
+import org.apache.hop.resource.ResourceReference;
+import org.apache.hop.workflow.action.ActionBase;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/** FtpHelper test */
+@ExtendWith(MockitoExtension.class)
+class FtpHelperTests {
+  @Mock private ActionBase action;
+  @Mock private IResourceHolder holder;
+  private List<ResourceReference> references;
+
+  @BeforeEach
+  void setUp() {
+    references = new ArrayList<>();
+  }
+
+  @Test
+  void shouldAddServerResourceWhenServerNameIsValid() {
+    String serverName = "test_server";
+    String resolveServer = "resolve_server";
+
+    when(action.resolve(serverName)).thenReturn(resolveServer);
+    FtpHelper.addServerResourceReferenceIfPresent(references, serverName, 
action, holder);
+    assertEquals(1, references.size());
+
+    ResourceReference reference = references.get(0);
+    assertEquals(holder, reference.getReferenceHolder());
+    assertEquals(1, reference.getEntries().size());
+
+    ResourceEntry entry = reference.getEntries().get(0);
+    assertEquals(resolveServer, entry.getResource());
+    assertEquals(ResourceEntry.ResourceType.SERVER, entry.getResourcetype());
+  }
+
+  @Test
+  void shouldNotAddReferenceWhenServerNameIsEmpty() {
+    FtpHelper.addServerResourceReferenceIfPresent(references, null, action, 
holder);
+    FtpHelper.addServerResourceReferenceIfPresent(references, 
Const.EMPTY_STRING, action, holder);
+
+    assertTrue(references.isEmpty());
+    verify(action, never()).resolve(anyString());
+  }
+
+  @Test
+  void shouldUseResolveMethodToExpandVariables() {
+    String serverName = "test_server";
+    String resolveServer = "resolve_server";
+
+    when(action.resolve(serverName)).thenReturn(resolveServer);
+    FtpHelper.addServerResourceReferenceIfPresent(references, serverName, 
action, holder);
+
+    verify(action, only()).resolve(serverName);
+    assertEquals(resolveServer, 
references.get(0).getEntries().get(0).getResource());
+  }
+}
diff --git 
a/plugins/transforms/addsequence/src/main/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMeta.java
 
b/plugins/transforms/addsequence/src/main/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMeta.java
index 9b37505c7f..8a2cd68011 100644
--- 
a/plugins/transforms/addsequence/src/main/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMeta.java
+++ 
b/plugins/transforms/addsequence/src/main/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMeta.java
@@ -107,50 +107,24 @@ public class AddSequenceMeta extends 
BaseTransformMeta<AddSequence, AddSequenceD
   /**
    * @param maxValue The maxValue to set.
    */
-  public void setMaxValue(long maxValue) {
+  public void setMaxValueByValue(long maxValue) {
     this.maxValue = Long.toString(maxValue);
   }
 
-  /**
-   * @param maxValue The maxValue to set.
-   */
-  public void setMaxValue(String maxValue) {
-    this.maxValue = maxValue;
-  }
-
   /**
    * @param startAt The starting point of the sequence to set.
    */
-  public void setStartAt(long startAt) {
+  public void setStartAtByValue(long startAt) {
     this.startAt = Long.toString(startAt);
   }
 
   /**
    * @param incrementBy The incrementBy to set.
    */
-  public void setIncrementBy(long incrementBy) {
+  public void setIncrementByValue(long incrementBy) {
     this.incrementBy = Long.toString(incrementBy);
   }
 
-  /**
-   * @param incrementBy The incrementBy to set.
-   */
-  public void setIncrementBy(String incrementBy) {
-    this.incrementBy = incrementBy;
-  }
-
-  /**
-   * @param startAt The starting point of the sequence to set.
-   */
-  public void setStartAt(String startAt) {
-    this.startAt = startAt;
-  }
-
-  @Override
-  public Object clone() {
-    return super.clone();
-  }
-
   @Override
   public void setDefault() {
     valueName = "valuename";
diff --git 
a/plugins/transforms/addsequence/src/test/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMetaTest.java
 
b/plugins/transforms/addsequence/src/test/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMetaTest.java
index a72a3f6ee4..2c557ea085 100644
--- 
a/plugins/transforms/addsequence/src/test/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMetaTest.java
+++ 
b/plugins/transforms/addsequence/src/test/java/org/apache/hop/pipeline/transforms/addsequence/AddSequenceMetaTest.java
@@ -123,13 +123,13 @@ class AddSequenceMetaTest {
     assertEquals("1000", meta.getMaxValue());
 
     // Test long setters
-    meta.setStartAt(100L);
+    meta.setStartAtByValue(100L);
     assertEquals("100", meta.getStartAt());
 
-    meta.setIncrementBy(10L);
+    meta.setIncrementByValue(10L);
     assertEquals("10", meta.getIncrementBy());
 
-    meta.setMaxValue(10000L);
+    meta.setMaxValueByValue(10000L);
     assertEquals("10000", meta.getMaxValue());
   }
 
diff --git 
a/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/AddSnowflakeIdData.java
 
b/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/AddSnowflakeIdData.java
index 2b41b19bd9..d87b37f4e5 100644
--- 
a/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/AddSnowflakeIdData.java
+++ 
b/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/AddSnowflakeIdData.java
@@ -22,6 +22,7 @@ import org.apache.hop.pipeline.transform.BaseTransformData;
 import org.apache.hop.pipeline.transform.ITransformData;
 
 /** AddSnowflakeId data */
+@SuppressWarnings("java:S1104")
 public class AddSnowflakeIdData extends BaseTransformData implements 
ITransformData {
   public IRowMeta outputRowMeta;
 }
diff --git 
a/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/SnowflakeSafeIdGenerator.java
 
b/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/SnowflakeSafeIdGenerator.java
index 41634e22de..74f6a6c294 100644
--- 
a/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/SnowflakeSafeIdGenerator.java
+++ 
b/plugins/transforms/addsnowflakeid/src/main/java/org/apache/hop/pipeline/transforms/addsnowflakeid/SnowflakeSafeIdGenerator.java
@@ -70,7 +70,9 @@ public class SnowflakeSafeIdGenerator {
    *
    * @return SnowflakeSafeIdGenerator
    */
+  @SuppressWarnings("java:S2245")
   public static SnowflakeSafeIdGenerator createDefault() {
+    // ThreadLocalRandom is safe for non-crypto purposes
     long dataCenterId = ThreadLocalRandom.current().nextInt(0, 32);
     long machineId = ThreadLocalRandom.current().nextInt(0, 32);
     return new SnowflakeSafeIdGenerator(dataCenterId, machineId, 10);

Reply via email to