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 082182b8ad Include subfolders option did not work correctly, fixes
#7952 (#7957)
082182b8ad is described below
commit 082182b8ade3fa43ba27bca8a90b46f1aa1557dc
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Sat Aug 15 10:24:00 2026 +0200
Include subfolders option did not work correctly, fixes #7952 (#7957)
---
.../actions/main-0021-delete-files-subfolders.hwf | 431 +++++++++++++++++++++
.../ActionAddResultFilenames.java | 2 +-
.../ActionAddResultFilenamesSubfolderTest.java | 121 ++++++
.../actions/deletefiles/ActionDeleteFiles.java | 2 +-
.../ActionDeleteFilesSubfolderTest.java | 107 +++++
.../actions/folderisempty/ActionFolderIsEmpty.java | 2 +-
.../ActionFolderIsEmptySubfolderTest.java | 126 ++++++
.../folderscompare/ActionFoldersCompare.java | 2 +-
.../ActionFoldersCompareSubfolderTest.java | 131 +++++++
.../actions/movefiles/ActionMoveFiles.java | 2 +-
.../movefiles/ActionMoveFilesSubfolderTest.java | 122 ++++++
.../actions/xml/xmlwellformed/XmlWellFormed.java | 2 +-
.../xmlwellformed/XmlWellFormedSubfolderTest.java | 125 ++++++
.../hop/mail/pipeline/transforms/mail/Mail.java | 4 +-
.../transforms/mail/MailSubfolderTest.java | 135 +++++++
15 files changed, 1306 insertions(+), 8 deletions(-)
diff --git a/integration-tests/actions/main-0021-delete-files-subfolders.hwf
b/integration-tests/actions/main-0021-delete-files-subfolders.hwf
new file mode 100644
index 0000000000..2f09d8a8c5
--- /dev/null
+++ b/integration-tests/actions/main-0021-delete-files-subfolders.hwf
@@ -0,0 +1,431 @@
+<?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-0021-delete-files-subfolders</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Delete files honours the "include subfolders" option, also for
folder traversal (issue #7952)</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/08/14 15:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/14 15:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <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>48</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Create test tree</name>
+ <description>Builds a folder holding matching files, a non-matching
file, a readable subfolder and a subfolder the running user cannot
read.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <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
+
+BASE="${PROJECT_HOME}/workfiles-0021"
+
+# Self-heal after an aborted earlier run: the unreadable folder is still ours
to chmod back.
+chmod -R u+rwX "$BASE" 2>/dev/null || true
+rm -rf "$BASE"
+
+mkdir -p "$BASE/readable-sub" "$BASE/no-access" || exit 1
+
+echo "a" > "$BASE/cams_a.csv"
+echo "b" > "$BASE/cams_b.csv"
+echo "keep me" > "$BASE/keep.txt"
+echo "nested" > "$BASE/readable-sub/nested.csv"
+echo "hidden" > "$BASE/no-access/hidden.csv"
+
+# The scenario from issue #7952: a subfolder the Hop user has no permission to
read.
+chmod 000 "$BASE/no-access"
+
+if ls "$BASE/no-access" >/dev/null 2>&1; then
+ echo "WARNING: $BASE/no-access is still readable (running as root?)."
+ echo "WARNING: the traversal half of this test is not exercised, the
wildcard half still is."
+else
+ echo "OK: $BASE/no-access is not readable, as intended"
+fi
+
+echo "Test tree created in $BASE"
+exit 0</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>192</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Delete csv without subfolders</name>
+ <description>Must delete the matching files in the base folder and leave
every subfolder alone, unreadable or not.</description>
+ <type>DELETE_FILES</type>
+ <attributes/>
+ <arg_from_previous>N</arg_from_previous>
+ <include_subfolders>N</include_subfolders>
+ <fields>
+ <field>
+ <name>${PROJECT_HOME}/workfiles-0021</name>
+ <filemask>.*\.csv</filemask>
+ </field>
+ </fields>
+ <parallel>N</parallel>
+ <xloc>384</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Verify base folder only</name>
+ <description/>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <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
+
+BASE="${PROJECT_HOME}/workfiles-0021"
+rc=0
+
+check_gone() {
+ if [ -e "$1" ]; then
+ echo "FAIL: $1 should have been deleted"
+ rc=1
+ else
+ echo "OK: $1 is deleted"
+ fi
+}
+
+check_present() {
+ if [ -e "$1" ]; then
+ echo "OK: $1 is kept"
+ else
+ echo "FAIL: $1 should have been kept"
+ rc=1
+ fi
+}
+
+check_gone "$BASE/cams_a.csv"
+check_gone "$BASE/cams_b.csv"
+check_present "$BASE/keep.txt"
+check_present "$BASE/readable-sub/nested.csv"
+check_present "$BASE/no-access"
+
+exit $rc</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>576</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Unlock subfolder</name>
+ <description>Give the permissions back so the recursive delete below is
a fair test.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <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
+
+chmod 755 "${PROJECT_HOME}/workfiles-0021/no-access" || exit 1
+echo "OK: permissions restored"
+exit 0</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>768</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Delete csv with subfolders</name>
+ <description>Same folder and mask, but now the subfolders have to be
walked.</description>
+ <type>DELETE_FILES</type>
+ <attributes/>
+ <arg_from_previous>N</arg_from_previous>
+ <include_subfolders>Y</include_subfolders>
+ <fields>
+ <field>
+ <name>${PROJECT_HOME}/workfiles-0021</name>
+ <filemask>.*\.csv</filemask>
+ </field>
+ </fields>
+ <parallel>N</parallel>
+ <xloc>960</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Verify recursive delete</name>
+ <description/>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <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
+
+BASE="${PROJECT_HOME}/workfiles-0021"
+rc=0
+
+check_gone() {
+ if [ -e "$1" ]; then
+ echo "FAIL: $1 should have been deleted"
+ rc=1
+ else
+ echo "OK: $1 is deleted"
+ fi
+}
+
+check_present() {
+ if [ -e "$1" ]; then
+ echo "OK: $1 is kept"
+ else
+ echo "FAIL: $1 should have been kept"
+ rc=1
+ fi
+}
+
+check_gone "$BASE/readable-sub/nested.csv"
+check_gone "$BASE/no-access/hidden.csv"
+check_present "$BASE/keep.txt"
+
+exit $rc</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>1152</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Clean up</name>
+ <description/>
+ <type>DELETE_FOLDERS</type>
+ <attributes/>
+ <arg_from_previous>N</arg_from_previous>
+ <success_condition>success_if_no_errors</success_condition>
+ <limit_folders>10</limit_folders>
+ <fields>
+ <field>
+ <name>${PROJECT_HOME}/workfiles-0021</name>
+ </field>
+ </fields>
+ <parallel>N</parallel>
+ <xloc>1344</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>1520</xloc>
+ <yloc>144</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Abort workflow</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <always_log_rows>N</always_log_rows>
+ <parallel>N</parallel>
+ <xloc>768</xloc>
+ <yloc>272</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Create test tree</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Create test tree</from>
+ <to>Delete csv without subfolders</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Create test tree</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Delete csv without subfolders</from>
+ <to>Verify base folder only</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Delete csv without subfolders</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Verify base folder only</from>
+ <to>Unlock subfolder</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Verify base folder only</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Unlock subfolder</from>
+ <to>Delete csv with subfolders</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Unlock subfolder</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Delete csv with subfolders</from>
+ <to>Verify recursive delete</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Delete csv with subfolders</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Verify recursive delete</from>
+ <to>Clean up</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Verify recursive delete</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Clean up</from>
+ <to>Success</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ <notepad>
+ <backgroundcolorblue>251</backgroundcolorblue>
+ <backgroundcolorgreen>232</backgroundcolorgreen>
+ <backgroundcolorred>201</backgroundcolorred>
+ <bordercolorblue>90</bordercolorblue>
+ <bordercolorgreen>58</bordercolorgreen>
+ <bordercolorred>14</bordercolorred>
+ <fontbold>N</fontbold>
+ <fontcolorblue>90</fontcolorblue>
+ <fontcolorgreen>58</fontcolorgreen>
+ <fontcolorred>14</fontcolorred>
+ <fontitalic>N</fontitalic>
+ <fontname>.AppleSystemUIFont</fontname>
+ <fontsize>13</fontsize>
+ <height>60</height>
+ <xloc>32</xloc>
+ <yloc>32</yloc>
+ <note>Issue #7952: with "include subfolders" off, Delete files must not
walk into
+subfolders at all. It used to, which failed on subfolders the user cannot read.
+Uses chmod, so it only works under linux/docker.</note>
+ <width>560</width>
+ </notepad>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/plugins/actions/addresultfilenames/src/main/java/org/apache/hop/workflow/actions/addresultfilenames/ActionAddResultFilenames.java
b/plugins/actions/addresultfilenames/src/main/java/org/apache/hop/workflow/actions/addresultfilenames/ActionAddResultFilenames.java
index e050e268ee..454d2db53d 100644
---
a/plugins/actions/addresultfilenames/src/main/java/org/apache/hop/workflow/actions/addresultfilenames/ActionAddResultFilenames.java
+++
b/plugins/actions/addresultfilenames/src/main/java/org/apache/hop/workflow/actions/addresultfilenames/ActionAddResultFilenames.java
@@ -301,7 +301,7 @@ public class ActionAddResultFilenames extends ActionBase
implements Cloneable, I
@Override
public boolean traverseDescendents(FileSelectInfo info) {
- return true;
+ return info.getDepth() == 0 || includeSubFolders;
}
}
diff --git
a/plugins/actions/addresultfilenames/src/test/java/org/apache/hop/workflow/actions/addresultfilenames/ActionAddResultFilenamesSubfolderTest.java
b/plugins/actions/addresultfilenames/src/test/java/org/apache/hop/workflow/actions/addresultfilenames/ActionAddResultFilenamesSubfolderTest.java
new file mode 100644
index 0000000000..fc288a383e
--- /dev/null
+++
b/plugins/actions/addresultfilenames/src/test/java/org/apache/hop/workflow/actions/addresultfilenames/ActionAddResultFilenamesSubfolderTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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.addresultfilenames;
+
+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.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.List;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+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;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The "include subfolders" option has to control folder traversal as well,
otherwise a sub-folder
+ * the user has no access to breaks a run that was never meant to look inside
it. See
+ * https://github.com/apache/hop/issues/7952
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class ActionAddResultFilenamesSubfolderTest {
+
+ @TempDir Path folder;
+
+ private ActionAddResultFilenames action;
+ private Path unreadableFolder;
+
+ @BeforeAll
+ static void setUpBeforeClass() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ IWorkflowEngine<WorkflowMeta> workflow = new LocalWorkflowEngine(new
WorkflowMeta());
+ action = new ActionAddResultFilenames();
+ workflow.getWorkflowMeta().addAction(new ActionMeta(action));
+ action.setParentWorkflow(workflow);
+ action.setParentWorkflowMeta(mock(WorkflowMeta.class));
+ workflow.setStopped(false);
+ action.setArgFromPrevious(false);
+ action.setArguments(List.of(new Argument(folder.toString(), null)));
+ }
+
+ @AfterEach
+ void restorePermissions() throws IOException {
+ if (unreadableFolder != null) {
+ Files.setPosixFilePermissions(unreadableFolder,
PosixFilePermissions.fromString("rwx------"));
+ }
+ }
+
+ @Test
+ void unreadableSubfolderIsNotTraversedWhenSubfoldersAreExcluded() throws
Exception {
+ Files.createFile(folder.resolve("report.csv"));
+ unreadableFolder = createUnreadableFolder();
+ action.setIncludeSubFolders(false);
+
+ Result result = action.execute(new Result(), 0);
+
+ assertEquals(0, result.getNrErrors(), "an excluded sub-folder must not
cause an error");
+ assertTrue(result.isResult());
+ assertEquals(1, result.getResultFiles().size(), "only the base folder file
is expected");
+ assertTrue(
+ result.getResultFiles().keySet().stream().anyMatch(k ->
k.endsWith("report.csv")),
+ "the base folder file must be added to the result files");
+ }
+
+ @Test
+ void subfoldersAreTraversedWhenIncluded() throws Exception {
+ Files.createFile(folder.resolve("report.csv"));
+ Path subFolder = Files.createDirectory(folder.resolve("archive"));
+ Files.createFile(subFolder.resolve("nested.csv"));
+
+ action.setIncludeSubFolders(false);
+ Result result = action.execute(new Result(), 0);
+ assertEquals(1, result.getResultFiles().size(), "the nested file must be
skipped");
+
+ action.setIncludeSubFolders(true);
+ result = action.execute(new Result(), 0);
+ assertEquals(2, result.getResultFiles().size(), "the nested file must be
picked up");
+ }
+
+ private Path createUnreadableFolder() throws IOException {
+ Path unreadable = Files.createDirectory(folder.resolve("no-access"));
+ Files.createFile(unreadable.resolve("hidden.csv"));
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("---------"));
+ assumeTrue(
+ unreadable.toFile().list() == null,
+ "folder is still readable, the test cannot run as this user (root?)");
+ return unreadable;
+ }
+}
diff --git
a/plugins/actions/deletefiles/src/main/java/org/apache/hop/workflow/actions/deletefiles/ActionDeleteFiles.java
b/plugins/actions/deletefiles/src/main/java/org/apache/hop/workflow/actions/deletefiles/ActionDeleteFiles.java
index d9910e4b1b..9eb383c2ee 100644
---
a/plugins/actions/deletefiles/src/main/java/org/apache/hop/workflow/actions/deletefiles/ActionDeleteFiles.java
+++
b/plugins/actions/deletefiles/src/main/java/org/apache/hop/workflow/actions/deletefiles/ActionDeleteFiles.java
@@ -320,7 +320,7 @@ public class ActionDeleteFiles extends ActionBase {
@Override
public boolean traverseDescendents(FileSelectInfo info) {
- return true;
+ return info.getDepth() == 0 || includeSubfolders;
}
}
diff --git
a/plugins/actions/deletefiles/src/test/java/org/apache/hop/workflow/actions/deletefiles/ActionDeleteFilesSubfolderTest.java
b/plugins/actions/deletefiles/src/test/java/org/apache/hop/workflow/actions/deletefiles/ActionDeleteFilesSubfolderTest.java
new file mode 100644
index 0000000000..da063c0004
--- /dev/null
+++
b/plugins/actions/deletefiles/src/test/java/org/apache/hop/workflow/actions/deletefiles/ActionDeleteFilesSubfolderTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.deletefiles;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Comparator;
+import java.util.stream.Stream;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogLevel;
+import org.apache.hop.workflow.Workflow;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.engine.IWorkflowEngine;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Verifies that the "include subfolders" option also controls folder
traversal. */
+class ActionDeleteFilesSubfolderTest {
+
+ private static final String CSV_MASK = ".*\\.csv";
+
+ @BeforeAll
+ static void setUpBeforeClass() throws Exception {
+ HopLogStore.init();
+ HopEnvironment.init();
+ }
+
+ private ActionDeleteFiles createAction(boolean includeSubfolders) {
+ ActionDeleteFiles action = new ActionDeleteFiles();
+ IWorkflowEngine<WorkflowMeta> parentWorkflow = mock(Workflow.class);
+ doReturn(false).when(parentWorkflow).isStopped();
+ doReturn(LogLevel.BASIC).when(parentWorkflow).getLogLevel();
+ action.setParentWorkflow(parentWorkflow);
+ action.setParentWorkflowMeta(mock(WorkflowMeta.class));
+ action.setIncludeSubfolders(includeSubfolders);
+ return action;
+ }
+
+ /** An inaccessible subfolder must not break a delete that was not asked to
recurse. */
+ @Test
+ void inaccessibleSubfolderIsNotTraversedWhenSubfoldersAreExcluded(@TempDir
Path folder)
+ throws Exception {
+ Path matching = Files.createFile(folder.resolve("cams_20260814.csv"));
+ Path notMatching = Files.createFile(folder.resolve("readme.txt"));
+ Path unreadable = Files.createDirectory(folder.resolve("mcbp-full"));
+ Files.createFile(unreadable.resolve("nested.csv"));
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("---------"));
+
+ ActionDeleteFiles action = createAction(false);
+ try {
+ assertTrue(action.processFile(folder.toString(), CSV_MASK,
action.getParentWorkflow()));
+
+ assertFalse(Files.exists(matching), "matching file in the base folder
was not deleted");
+ assertTrue(Files.exists(notMatching), "non-matching file was deleted");
+ } finally {
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("rwx------"));
+ deleteRecursively(unreadable);
+ }
+ }
+
+ /** Recursion still happens when subfolders are included. */
+ @Test
+ void subfoldersAreTraversedWhenIncluded(@TempDir Path folder) throws
Exception {
+ Path matching = Files.createFile(folder.resolve("cams_20260814.csv"));
+ Path subFolder = Files.createDirectory(folder.resolve("archive"));
+ Path nestedMatching = Files.createFile(subFolder.resolve("nested.csv"));
+ Path nestedNotMatching = Files.createFile(subFolder.resolve("nested.txt"));
+
+ ActionDeleteFiles action = createAction(true);
+
+ assertTrue(action.processFile(folder.toString(), CSV_MASK,
action.getParentWorkflow()));
+
+ assertFalse(Files.exists(matching), "matching file in the base folder was
not deleted");
+ assertFalse(Files.exists(nestedMatching), "matching file in the subfolder
was not deleted");
+ assertTrue(Files.exists(nestedNotMatching), "non-matching file was
deleted");
+ }
+
+ private void deleteRecursively(Path path) throws IOException {
+ try (Stream<Path> paths = Files.walk(path)) {
+
paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(java.io.File::delete);
+ }
+ }
+}
diff --git
a/plugins/actions/folderisempty/src/main/java/org/apache/hop/workflow/actions/folderisempty/ActionFolderIsEmpty.java
b/plugins/actions/folderisempty/src/main/java/org/apache/hop/workflow/actions/folderisempty/ActionFolderIsEmpty.java
index 4704b2244b..f01f4a5de2 100644
---
a/plugins/actions/folderisempty/src/main/java/org/apache/hop/workflow/actions/folderisempty/ActionFolderIsEmpty.java
+++
b/plugins/actions/folderisempty/src/main/java/org/apache/hop/workflow/actions/folderisempty/ActionFolderIsEmpty.java
@@ -290,7 +290,7 @@ public class ActionFolderIsEmpty extends ActionBase
implements Cloneable, IActio
@Override
public boolean traverseDescendents(FileSelectInfo info) {
- return true;
+ return info.getDepth() == 0 || isIncludeSubFolders();
}
}
diff --git
a/plugins/actions/folderisempty/src/test/java/org/apache/hop/workflow/actions/folderisempty/ActionFolderIsEmptySubfolderTest.java
b/plugins/actions/folderisempty/src/test/java/org/apache/hop/workflow/actions/folderisempty/ActionFolderIsEmptySubfolderTest.java
new file mode 100644
index 0000000000..6bf961d314
--- /dev/null
+++
b/plugins/actions/folderisempty/src/test/java/org/apache/hop/workflow/actions/folderisempty/ActionFolderIsEmptySubfolderTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.folderisempty;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+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;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The "include subfolders" option has to control folder traversal as well,
otherwise a sub-folder
+ * the user has no access to breaks a check that was never meant to look
inside it. See
+ * https://github.com/apache/hop/issues/7952
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class ActionFolderIsEmptySubfolderTest {
+
+ @TempDir Path folder;
+
+ private ActionFolderIsEmpty action;
+ private Path unreadableFolder;
+
+ @BeforeAll
+ static void setUpBeforeClass() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ IWorkflowEngine<WorkflowMeta> workflow = new LocalWorkflowEngine(new
WorkflowMeta());
+ action = new ActionFolderIsEmpty();
+ workflow.getWorkflowMeta().addAction(new ActionMeta(action));
+ action.setParentWorkflow(workflow);
+ action.setParentWorkflowMeta(mock(WorkflowMeta.class));
+ workflow.setStopped(false);
+ action.setFolderName(folder.toString());
+ }
+
+ @AfterEach
+ void restorePermissions() throws IOException {
+ if (unreadableFolder != null) {
+ Files.setPosixFilePermissions(unreadableFolder,
PosixFilePermissions.fromString("rwx------"));
+ }
+ }
+
+ @Test
+ void unreadableSubfolderIsNotTraversedWhenSubfoldersAreExcluded() throws
Exception {
+ unreadableFolder = createUnreadableFolder("no-access");
+ action.setIncludeSubFolders(false);
+
+ Result result = action.execute(new Result(), 0);
+
+ assertEquals(0, result.getNrErrors(), "an excluded sub-folder must not
cause an error");
+ assertTrue(result.isResult(), "the folder holds no files of its own, so it
is empty");
+ }
+
+ @Test
+ void unreadableSubfolderIsStillReportedWhenSubfoldersAreIncluded() throws
Exception {
+ unreadableFolder = createUnreadableFolder("no-access");
+ action.setIncludeSubFolders(true);
+
+ Result result = action.execute(new Result(), 0);
+
+ assertEquals(1, result.getNrErrors(), "an included sub-folder that cannot
be read is an error");
+ }
+
+ @Test
+ void subfoldersAreTraversedWhenIncluded() throws Exception {
+ Path subFolder = Files.createDirectory(folder.resolve("archive"));
+ Files.createFile(subFolder.resolve("nested.txt"));
+
+ action.setIncludeSubFolders(false);
+ assertTrue(
+ action.execute(new Result(), 0).isResult(),
+ "only the base folder counts, so the folder is empty");
+
+ action.setIncludeSubFolders(true);
+ assertFalse(
+ action.execute(new Result(), 0).isResult(),
+ "the nested file must be seen when subfolders are included");
+ }
+
+ private Path createUnreadableFolder(String name) throws IOException {
+ Path unreadable = Files.createDirectory(folder.resolve(name));
+ Files.createFile(unreadable.resolve("hidden.txt"));
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("---------"));
+ assumeTrue(
+ unreadable.toFile().list() == null,
+ "folder is still readable, the test cannot run as this user (root?)");
+ return unreadable;
+ }
+}
diff --git
a/plugins/actions/folderscompare/src/main/java/org/apache/hop/workflow/actions/folderscompare/ActionFoldersCompare.java
b/plugins/actions/folderscompare/src/main/java/org/apache/hop/workflow/actions/folderscompare/ActionFoldersCompare.java
index efd9489a96..356ef35a25 100644
---
a/plugins/actions/folderscompare/src/main/java/org/apache/hop/workflow/actions/folderscompare/ActionFoldersCompare.java
+++
b/plugins/actions/folderscompare/src/main/java/org/apache/hop/workflow/actions/folderscompare/ActionFoldersCompare.java
@@ -499,7 +499,7 @@ public class ActionFoldersCompare extends ActionBase
implements Cloneable, IActi
@Override
public boolean traverseDescendents(FileSelectInfo info) {
- return true;
+ return info.getDepth() == 0 || includeSubFolders;
}
}
diff --git
a/plugins/actions/folderscompare/src/test/java/org/apache/hop/workflow/actions/folderscompare/ActionFoldersCompareSubfolderTest.java
b/plugins/actions/folderscompare/src/test/java/org/apache/hop/workflow/actions/folderscompare/ActionFoldersCompareSubfolderTest.java
new file mode 100644
index 0000000000..b20409c933
--- /dev/null
+++
b/plugins/actions/folderscompare/src/test/java/org/apache/hop/workflow/actions/folderscompare/ActionFoldersCompareSubfolderTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.folderscompare;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.ArrayList;
+import java.util.List;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+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;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The "include subfolders" option has to control folder traversal as well,
otherwise a sub-folder
+ * the user has no access to breaks a comparison that was never meant to look
inside it. See
+ * https://github.com/apache/hop/issues/7952
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class ActionFoldersCompareSubfolderTest {
+
+ @TempDir Path testFolder;
+
+ private ActionFoldersCompare action;
+ private Path folder1;
+ private Path folder2;
+ private final List<Path> unreadableFolders = new ArrayList<>();
+
+ @BeforeAll
+ static void setUpBeforeClass() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ IWorkflowEngine<WorkflowMeta> workflow = new LocalWorkflowEngine(new
WorkflowMeta());
+ action = new ActionFoldersCompare();
+ workflow.getWorkflowMeta().addAction(new ActionMeta(action));
+ action.setParentWorkflow(workflow);
+ action.setParentWorkflowMeta(mock(WorkflowMeta.class));
+ workflow.setStopped(false);
+
+ folder1 = Files.createDirectories(testFolder.resolve("left"));
+ folder2 = Files.createDirectories(testFolder.resolve("right"));
+ action.setFilename1(folder1.toString());
+ action.setFilename2(folder2.toString());
+ action.setCompareOnly("all");
+ }
+
+ @AfterEach
+ void restorePermissions() throws IOException {
+ for (Path unreadable : unreadableFolders) {
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("rwx------"));
+ }
+ }
+
+ @Test
+ void unreadableSubfolderIsNotTraversedWhenSubfoldersAreExcluded() throws
Exception {
+ Files.writeString(folder1.resolve("report.csv"), "same",
StandardCharsets.UTF_8);
+ Files.writeString(folder2.resolve("report.csv"), "same",
StandardCharsets.UTF_8);
+ createUnreadableFolder(folder1);
+ createUnreadableFolder(folder2);
+ action.setIncludeSubFolders(false);
+
+ Result result = action.execute(new Result(), 0);
+
+ assertTrue(result.isResult(), "identical base folders must compare as
equal");
+ }
+
+ @Test
+ void subfoldersAreTraversedWhenIncluded() throws Exception {
+ Files.writeString(folder1.resolve("report.csv"), "same",
StandardCharsets.UTF_8);
+ Files.writeString(folder2.resolve("report.csv"), "same",
StandardCharsets.UTF_8);
+ // only the left side has a nested file, so the folders differ below the
base level
+ Path subFolder1 = Files.createDirectory(folder1.resolve("archive"));
+ Files.writeString(subFolder1.resolve("nested.csv"), "left",
StandardCharsets.UTF_8);
+ Files.createDirectory(folder2.resolve("archive"));
+
+ action.setIncludeSubFolders(false);
+ assertTrue(
+ action.execute(new Result(), 0).isResult(),
+ "the nested difference must be ignored when subfolders are excluded");
+
+ action.setIncludeSubFolders(true);
+ assertFalse(
+ action.execute(new Result(), 0).isResult(),
+ "the nested difference must be seen when subfolders are included");
+ }
+
+ private void createUnreadableFolder(Path parent) throws IOException {
+ Path unreadable = Files.createDirectory(parent.resolve("no-access"));
+ Files.createFile(unreadable.resolve("hidden.csv"));
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("---------"));
+ unreadableFolders.add(unreadable);
+ assumeTrue(
+ unreadable.toFile().list() == null,
+ "folder is still readable, the test cannot run as this user (root?)");
+ }
+}
diff --git
a/plugins/actions/movefiles/src/main/java/org/apache/hop/workflow/actions/movefiles/ActionMoveFiles.java
b/plugins/actions/movefiles/src/main/java/org/apache/hop/workflow/actions/movefiles/ActionMoveFiles.java
index 1182d6df37..831f4df335 100644
---
a/plugins/actions/movefiles/src/main/java/org/apache/hop/workflow/actions/movefiles/ActionMoveFiles.java
+++
b/plugins/actions/movefiles/src/main/java/org/apache/hop/workflow/actions/movefiles/ActionMoveFiles.java
@@ -578,7 +578,7 @@ public class ActionMoveFiles extends ActionBase implements
Cloneable, IAction {
new AllFileSelector() {
@Override
public boolean traverseDescendents(FileSelectInfo
info) {
- return true;
+ return info.getDepth() == 0 || includeSubfolders;
}
@Override
diff --git
a/plugins/actions/movefiles/src/test/java/org/apache/hop/workflow/actions/movefiles/ActionMoveFilesSubfolderTest.java
b/plugins/actions/movefiles/src/test/java/org/apache/hop/workflow/actions/movefiles/ActionMoveFilesSubfolderTest.java
new file mode 100644
index 0000000000..b9f995b1a6
--- /dev/null
+++
b/plugins/actions/movefiles/src/test/java/org/apache/hop/workflow/actions/movefiles/ActionMoveFilesSubfolderTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.movefiles;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.logging.HopLogStore;
+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;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The "include subfolders" option has to control folder traversal as well,
otherwise a sub-folder
+ * the user has no access to breaks a move that was never meant to look inside
it. See
+ * https://github.com/apache/hop/issues/7952
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class ActionMoveFilesSubfolderTest {
+
+ @TempDir Path testFolder;
+
+ private ActionMoveFiles action;
+ private Path sourceFolder;
+ private Path destinationFolder;
+ private Path unreadableFolder;
+
+ @BeforeAll
+ static void setUpBeforeClass() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ action = MoveFilesActionHelper.defaultAction();
+ sourceFolder = Files.createDirectories(testFolder.resolve("source"));
+ destinationFolder =
Files.createDirectories(testFolder.resolve("destination"));
+
+ ActionMoveFiles.FileToMove fileToMove = new ActionMoveFiles.FileToMove();
+ fileToMove.setSourceFileFolder(sourceFolder.toString());
+ fileToMove.setDestinationFileFolder(destinationFolder.toString());
+ action.getFilesToMove().add(fileToMove);
+ action.setDestinationIsAFile(false);
+ }
+
+ @AfterEach
+ void restorePermissions() throws IOException {
+ if (unreadableFolder != null) {
+ Files.setPosixFilePermissions(unreadableFolder,
PosixFilePermissions.fromString("rwx------"));
+ }
+ }
+
+ @Test
+ void unreadableSubfolderIsNotTraversedWhenSubfoldersAreExcluded() throws
Exception {
+ Files.createFile(sourceFolder.resolve("report.csv"));
+ unreadableFolder = createUnreadableFolder();
+ action.setIncludeSubfolders(false);
+
+ Result result = action.execute(new Result(), 0);
+
+ assertTrue(result.isResult(), "an excluded sub-folder must not fail the
move");
+ assertTrue(
+ Files.exists(destinationFolder.resolve("report.csv")),
+ "the base folder file must still be moved");
+ }
+
+ @Test
+ void subfoldersAreTraversedWhenIncluded() throws Exception {
+ Files.createFile(sourceFolder.resolve("report.csv"));
+ Path subFolder = Files.createDirectory(sourceFolder.resolve("archive"));
+ Files.createFile(subFolder.resolve("nested.csv"));
+
+ action.setIncludeSubfolders(false);
+ assertTrue(action.execute(new Result(), 0).isResult());
+ assertTrue(Files.exists(destinationFolder.resolve("report.csv")),
"report.csv must be moved");
+ assertTrue(Files.exists(subFolder.resolve("nested.csv")), "the nested file
must be left alone");
+
+ action.setIncludeSubfolders(true);
+ assertTrue(action.execute(new Result(), 0).isResult());
+ assertFalse(
+ Files.exists(subFolder.resolve("nested.csv")),
+ "the nested file must be moved when subfolders are included");
+ assertTrue(
+
Files.exists(destinationFolder.resolve("archive").resolve("nested.csv")),
+ "the nested file must arrive under its sub-folder");
+ }
+
+ private Path createUnreadableFolder() throws IOException {
+ Path unreadable = Files.createDirectory(sourceFolder.resolve("no-access"));
+ Files.createFile(unreadable.resolve("hidden.csv"));
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("---------"));
+ assumeTrue(
+ unreadable.toFile().list() == null,
+ "folder is still readable, the test cannot run as this user (root?)");
+ return unreadable;
+ }
+}
diff --git
a/plugins/actions/xml/src/main/java/org/apache/hop/workflow/actions/xml/xmlwellformed/XmlWellFormed.java
b/plugins/actions/xml/src/main/java/org/apache/hop/workflow/actions/xml/xmlwellformed/XmlWellFormed.java
index d4432924bd..472071918b 100644
---
a/plugins/actions/xml/src/main/java/org/apache/hop/workflow/actions/xml/xmlwellformed/XmlWellFormed.java
+++
b/plugins/actions/xml/src/main/java/org/apache/hop/workflow/actions/xml/xmlwellformed/XmlWellFormed.java
@@ -359,7 +359,7 @@ public class XmlWellFormed extends ActionBase implements
Cloneable, IAction {
new AllFileSelector() {
@Override
public boolean traverseDescendents(FileSelectInfo info) {
- return true;
+ return info.getDepth() == 0 || includeSubfolders;
}
@Override
diff --git
a/plugins/actions/xml/src/test/java/org/apache/hop/workflow/actions/xml/xmlwellformed/XmlWellFormedSubfolderTest.java
b/plugins/actions/xml/src/test/java/org/apache/hop/workflow/actions/xml/xmlwellformed/XmlWellFormedSubfolderTest.java
new file mode 100644
index 0000000000..0bc712b62f
--- /dev/null
+++
b/plugins/actions/xml/src/test/java/org/apache/hop/workflow/actions/xml/xmlwellformed/XmlWellFormedSubfolderTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.xml.xmlwellformed;
+
+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.List;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+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;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The "include subfolders" option has to control folder traversal as well,
otherwise a sub-folder
+ * the user has no access to breaks a check that was never meant to look
inside it. See
+ * https://github.com/apache/hop/issues/7952
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class XmlWellFormedSubfolderTest {
+
+ private static final String WELL_FORMED = "<?xml
version=\"1.0\"?><root><a>1</a></root>";
+ private static final String MALFORMED = "<?xml
version=\"1.0\"?><root><a>1</root>";
+
+ @TempDir Path folder;
+
+ private XmlWellFormed action;
+ private Path unreadableFolder;
+
+ @BeforeAll
+ static void setUpBeforeClass() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ IWorkflowEngine<WorkflowMeta> workflow = new LocalWorkflowEngine(new
WorkflowMeta());
+ action = new XmlWellFormed();
+ workflow.getWorkflowMeta().addAction(new ActionMeta(action));
+ action.setParentWorkflow(workflow);
+ action.setParentWorkflowMeta(mock(WorkflowMeta.class));
+ workflow.setStopped(false);
+
+ XmlWellFormedField field = new XmlWellFormedField();
+ field.setSourceFilefolder(folder.toString());
+ field.setWildcard(".*\\.xml");
+ action.setSourceFileFolders(List.of(field));
+ }
+
+ @AfterEach
+ void restorePermissions() throws IOException {
+ if (unreadableFolder != null) {
+ Files.setPosixFilePermissions(unreadableFolder,
PosixFilePermissions.fromString("rwx------"));
+ }
+ }
+
+ @Test
+ void unreadableSubfolderIsNotTraversedWhenSubfoldersAreExcluded() throws
Exception {
+ Files.writeString(folder.resolve("good.xml"), WELL_FORMED,
StandardCharsets.UTF_8);
+ unreadableFolder = createUnreadableFolder();
+ action.includeSubfolders = false;
+
+ Result result = action.execute(new Result(), 0);
+
+ assertEquals(0, result.getNrErrors(), "an excluded sub-folder must not
cause an error");
+ assertTrue(result.isResult());
+ assertEquals(1, result.getNrLinesWritten(), "only the base folder file is
checked");
+ }
+
+ @Test
+ void subfoldersAreTraversedWhenIncluded() throws Exception {
+ Files.writeString(folder.resolve("good.xml"), WELL_FORMED,
StandardCharsets.UTF_8);
+ Path subFolder = Files.createDirectory(folder.resolve("archive"));
+ Files.writeString(subFolder.resolve("bad.xml"), MALFORMED,
StandardCharsets.UTF_8);
+
+ action.includeSubfolders = false;
+ Result result = action.execute(new Result(), 0);
+ assertEquals(0, result.getNrLinesRejected(), "the nested malformed file
must be skipped");
+
+ action.includeSubfolders = true;
+ result = action.execute(new Result(), 0);
+ assertEquals(1, result.getNrLinesRejected(), "the nested malformed file
must be reported");
+ }
+
+ private Path createUnreadableFolder() throws IOException {
+ Path unreadable = Files.createDirectory(folder.resolve("no-access"));
+ Files.writeString(unreadable.resolve("hidden.xml"), WELL_FORMED,
StandardCharsets.UTF_8);
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("---------"));
+ assumeTrue(
+ unreadable.toFile().list() == null,
+ "folder is still readable, the test cannot run as this user (root?)");
+ return unreadable;
+ }
+}
diff --git
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/pipeline/transforms/mail/Mail.java
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/pipeline/transforms/mail/Mail.java
index 1127094ab4..d61c95d7ef 100644
---
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/pipeline/transforms/mail/Mail.java
+++
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/pipeline/transforms/mail/Mail.java
@@ -1051,7 +1051,7 @@ public class Mail extends BaseTransform<MailMeta,
MailData> {
}
}
- private class TextFileSelector implements FileSelector {
+ class TextFileSelector implements FileSelector {
String fileWildcard = null;
String sourceFolder = null;
@@ -1095,7 +1095,7 @@ public class Mail extends BaseTransform<MailMeta,
MailData> {
@Override
public boolean traverseDescendents(FileSelectInfo info) {
- return true;
+ return info.getDepth() == 0 || meta.isIncludeSubFolders();
}
}
diff --git
a/plugins/misc/mail/src/test/java/org/apache/hop/mail/pipeline/transforms/mail/MailSubfolderTest.java
b/plugins/misc/mail/src/test/java/org/apache/hop/mail/pipeline/transforms/mail/MailSubfolderTest.java
new file mode 100644
index 0000000000..65f22a73c0
--- /dev/null
+++
b/plugins/misc/mail/src/test/java/org/apache/hop/mail/pipeline/transforms/mail/MailSubfolderTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.mail.pipeline.transforms.mail;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+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;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Attachments are collected with a VFS file selector. The "include
subfolders" option has to
+ * control folder traversal as well, otherwise a sub-folder the user has no
access to breaks a mail
+ * that was never meant to attach anything from it. See
https://github.com/apache/hop/issues/7952
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class MailSubfolderTest {
+
+ @TempDir Path folder;
+
+ private TransformMockHelper<MailMeta, MailData> mockHelper;
+ private MailMeta meta;
+ private Mail transform;
+ private Path unreadableFolder;
+
+ @BeforeAll
+ static void setUpBeforeClass() throws Exception {
+ HopClientEnvironment.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ mockHelper = new TransformMockHelper<>("Mail", MailMeta.class,
MailData.class);
+ when(mockHelper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+ .thenReturn(mockHelper.iLogChannel);
+ meta = mock(MailMeta.class);
+ transform =
+ new Mail(
+ mockHelper.transformMeta,
+ meta,
+ new MailData(),
+ 0,
+ mockHelper.pipelineMeta,
+ mockHelper.pipeline);
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ if (unreadableFolder != null) {
+ Files.setPosixFilePermissions(unreadableFolder,
PosixFilePermissions.fromString("rwx------"));
+ }
+ mockHelper.cleanUp();
+ }
+
+ @Test
+ void unreadableSubfolderIsNotTraversedWhenSubfoldersAreExcluded() throws
Exception {
+ Files.createFile(folder.resolve("report.csv"));
+ unreadableFolder = createUnreadableFolder();
+ when(meta.isIncludeSubFolders()).thenReturn(false);
+
+ List<String> found = findAttachments(".*\\.csv");
+
+ assertEquals(
+ List.of("report.csv"), found, "an excluded sub-folder must neither
fail nor contribute");
+ }
+
+ @Test
+ void subfoldersAreTraversedWhenIncluded() throws Exception {
+ Files.createFile(folder.resolve("report.csv"));
+ Path subFolder = Files.createDirectory(folder.resolve("archive"));
+ Files.createFile(subFolder.resolve("nested.csv"));
+
+ when(meta.isIncludeSubFolders()).thenReturn(false);
+ assertEquals(List.of("report.csv"), findAttachments(".*\\.csv"), "the
nested file is skipped");
+
+ when(meta.isIncludeSubFolders()).thenReturn(true);
+ assertEquals(
+ List.of("nested.csv", "report.csv"),
+ findAttachments(".*\\.csv"),
+ "the nested file is attached too");
+ }
+
+ /** Runs the transform's own attachment selector over the temp folder. */
+ private List<String> findAttachments(String wildcard) throws Exception {
+ try (FileObject sourceFolder = HopVfs.getFileObject(folder.toString())) {
+ FileObject[] list =
+ sourceFolder.findFiles(transform.new
TextFileSelector(sourceFolder.toString(), wildcard));
+ return Arrays.stream(list).map(file ->
file.getName().getBaseName()).sorted().toList();
+ }
+ }
+
+ private Path createUnreadableFolder() throws IOException {
+ Path unreadable = Files.createDirectory(folder.resolve("no-access"));
+ Files.createFile(unreadable.resolve("hidden.csv"));
+ Files.setPosixFilePermissions(unreadable,
PosixFilePermissions.fromString("---------"));
+ assumeTrue(
+ unreadable.toFile().list() == null,
+ "folder is still readable, the test cannot run as this user (root?)");
+ return unreadable;
+ }
+}