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" &gt; "$BASE/cams_a.csv"
+echo "b" &gt; "$BASE/cams_b.csv"
+echo "keep me" &gt; "$BASE/keep.txt"
+echo "nested" &gt; "$BASE/readable-sub/nested.csv"
+echo "hidden" &gt; "$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" &gt;/dev/null 2&gt;&amp;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;
+  }
+}

Reply via email to