This is an automated email from the ASF dual-hosted git repository.
mattcasters 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 a57df2fb1a Issue #9113 : List local Spark directories without Hadoop's
native Windows library (#9114)
a57df2fb1a is described below
commit a57df2fb1aae22505db27364ebce902ff92c4897
Author: Matt Casters <[email protected]>
AuthorDate: Thu Oct 29 22:45:10 2026 +0100
Issue #9113 : List local Spark directories without Hadoop's native Windows
library (#9114)
---
.../pipeline/spark/paths-and-filesystems.adoc | 4 +-
.../hop/spark/fs/HopWindowsLocalFileSystem.java | 95 ++++++++++++++++++++--
.../spark/fs/HopWindowsLocalFileSystemTest.java | 69 ++++++++++++++++
3 files changed, 159 insertions(+), 9 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/paths-and-filesystems.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/paths-and-filesystems.adoc
index d5107214d3..6152c9eac8 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/paths-and-filesystems.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/paths-and-filesystems.adoc
@@ -83,7 +83,9 @@ For the full list of Hop VFS schemes, see
xref:vfs.adoc[Virtual File Systems].
On Windows, Hadoop's local file system sets POSIX permissions by running
`winutils.exe` from `HADOOP_HOME` (or the `hadoop.home.dir` system property).
Spark file output hits that call when the commit creates the output directory.
With neither variable set, the write fails with `HADOOP_HOME and
hadoop.home.dir are unset`.
-When Hop creates the Spark session on Windows and `winutils.exe` is not
installed, local `file:///` reads and writes skip those permission changes. The
files keep the permissions of the Hop process. You do not need a Hadoop
installation for `local[*]`.
+When Hop creates the Spark session on Windows and `winutils.exe` is not
installed, local `file:///` reads and writes skip those permission changes.
Listing a directory during the output commit uses Java file checks as well.
Hadoop's own listing calls `NativeIO.Windows.access`, which needs `hadoop.dll`
and throws `UnsatisfiedLinkError` when that library is not installed. The files
keep the permissions of the Hop process. You do not need a Hadoop installation
for `local[*]`.
+
+A `file:///` path is this same local file system. A variable such as
`+${PROJECT_HOME}+` is often stored as a `file:///` URI, and resolving it keeps
that prefix. Spark File Output passes the resolved URI to Hadoop. A drive path
such as `C:/output` uses the same file system.
If `HADOOP_HOME` points at a directory that contains `bin/winutils.exe`, Hop
keeps Hadoop's own local file system and permissions are applied. To choose a
file system yourself, set `spark.hadoop.fs.file.impl` in the run configuration.
That entry wins.
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystem.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystem.java
index 0cf0a2c319..843b327d14 100644
---
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystem.java
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystem.java
@@ -17,7 +17,11 @@
package org.apache.hop.spark.fs;
+import java.io.File;
+import java.io.FileNotFoundException;
import java.io.IOException;
+import java.nio.file.AccessDeniedException;
+import java.util.Arrays;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.LocalFileSystem;
import org.apache.hadoop.fs.Path;
@@ -37,10 +41,18 @@ import org.apache.spark.sql.SparkSession;
* hadoop.home.dir}, and the call fails with {@code HADOOP_HOME and
hadoop.home.dir are unset} when
* neither is set. The file itself was already created by Java; only the
permission change fails.
*
- * <p>This class keeps the checksummed local file system and skips those
permission changes. Files
- * keep the permissions of the Hop process. It is installed only when Hop
creates the session, and
- * only when Windows has neither winutils nor Hadoop's native I/O library. An
explicit {@code
- * spark.hadoop.fs.file.impl} in the run configuration is applied afterwards
and wins.
+ * <p>This class keeps the checksummed local file system and skips those
permission changes. It also
+ * lists directories with {@link File} instead of {@code FileUtil.list}. On
Windows that helper
+ * calls {@code NativeIO.Windows.access} without checking that the native
library loaded, and the
+ * resulting {@link UnsatisfiedLinkError} is not an {@link IOException}. Spark
file output hits it
+ * when {@code FileOutputCommitter} lists the output directory. A {@code
file:///} path, including
+ * one produced by resolving a VFS variable such as {@code PROJECT_HOME}, is
this same local file
+ * system.
+ *
+ * <p>Files keep the permissions of the Hop process. The class is installed
only when Hop creates
+ * the session, and only when Windows has neither winutils nor Hadoop's native
I/O library. An
+ * explicit {@code spark.hadoop.fs.file.impl} in the run configuration is
applied afterwards and
+ * wins. A reused session keeps the file system it was created with.
*/
public class HopWindowsLocalFileSystem extends LocalFileSystem {
@@ -48,7 +60,12 @@ public class HopWindowsLocalFileSystem extends
LocalFileSystem {
public static final String SPARK_HADOOP_FILE_IMPL =
"spark.hadoop.fs.file.impl";
public HopWindowsLocalFileSystem() {
- super(new WinutilsFreeRawLocalFileSystem());
+ this(new WinutilsFreeRawLocalFileSystem());
+ }
+
+ /** Visible to tests that supply a raw file system with the Java directory
listing forced on. */
+ HopWindowsLocalFileSystem(WinutilsFreeRawLocalFileSystem raw) {
+ super(raw);
}
/**
@@ -89,18 +106,80 @@ public class HopWindowsLocalFileSystem extends
LocalFileSystem {
if (log != null) {
log.logBasic(
"HADOOP_HOME and hadoop.home.dir are unset. Local Spark paths will
keep the"
- + " permissions of this process instead of calling winutils.");
+ + " permissions of this process instead of calling winutils or
Hadoop's native"
+ + " Windows library.");
}
}
/**
- * Raw local file system that does not shell out for chmod or for owner
lookups.
+ * Raw local file system that does not shell out for chmod or for owner
lookups, and does not call
+ * the Windows native access check while listing a directory.
*
* <p>{@code mkdirs} and {@code create} call {@code setPermission} on this
instance, not on the
- * checksum wrapper, so the override has to live here.
+ * checksum wrapper. {@code ChecksumFileSystem.listStatus} calls {@code
listStatus} here. Both
+ * overrides have to live on this class.
*/
public static class WinutilsFreeRawLocalFileSystem extends
RawLocalFileSystem {
+ @Override
+ public FileStatus[] listStatus(Path f) throws IOException {
+ if (!skipNativeDirectoryListing()) {
+ return super.listStatus(f);
+ }
+ File localFile = pathToFile(f);
+ if (!localFile.exists()) {
+ throw new FileNotFoundException("File " + f + " does not exist");
+ }
+ if (!localFile.isDirectory()) {
+ return new FileStatus[] {getFileStatus(f)};
+ }
+ String[] names = listLocalNames(localFile);
+ FileStatus[] results = new FileStatus[names.length];
+ int kept = 0;
+ for (String name : names) {
+ try {
+ // The three-argument Path keeps a colon in a name from being read
as a scheme.
+ results[kept] = getFileStatus(new Path(f, new Path(null, null,
name)));
+ kept++;
+ } catch (FileNotFoundException ignored) {
+ // The directory changed between list() and getFileStatus().
+ }
+ }
+ if (kept == names.length) {
+ return results;
+ }
+ return Arrays.copyOf(results, kept);
+ }
+
+ /**
+ * Whether directory listing should use {@link File} instead of {@code
FileUtil.list}.
+ *
+ * <p>The default follows {@link HopWindowsLocalFileSystem#needed()}.
Tests override it so the
+ * Java listing can run on Linux, where {@code needed()} is false and
{@code FileUtil.canRead}
+ * does not call the Windows native method.
+ */
+ boolean skipNativeDirectoryListing() {
+ return HopWindowsLocalFileSystem.needed();
+ }
+
+ /**
+ * Names in a directory, using {@link File#canRead()} and {@link
File#list()}.
+ *
+ * <p>{@code FileUtil.list} refuses the directory through {@code
FileUtil.canRead}. On Windows
+ * that method always calls {@code NativeIO.Windows.access}. When {@code
hadoop.dll} is not
+ * loaded the native method throws {@link UnsatisfiedLinkError}.
+ */
+ static String[] listLocalNames(File directory) throws IOException {
+ if (!directory.canRead()) {
+ throw new AccessDeniedException(directory.toString(), null,
"Permission denied");
+ }
+ String[] names = directory.list();
+ if (names == null) {
+ throw new IOException("Invalid directory or I/O error occurred for
dir: " + directory);
+ }
+ return names;
+ }
+
@Override
public void setPermission(Path path, FsPermission permission) throws
IOException {
if (HopWindowsLocalFileSystem.needed()) {
diff --git
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystemTest.java
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystemTest.java
index 43d51a58ca..7ec5856dfc 100644
---
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystemTest.java
+++
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/fs/HopWindowsLocalFileSystemTest.java
@@ -20,10 +20,16 @@ package org.apache.hop.spark.fs;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.File;
import java.net.URI;
import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FSDataOutputStream;
@@ -31,6 +37,7 @@ import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.fs.permission.FsPermission;
import org.apache.hadoop.util.Shell;
+import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -78,6 +85,68 @@ class HopWindowsLocalFileSystemTest {
}
}
+ @Test
+ void listsAFileUriWithJavaInsteadOfTheNativeWindowsAccessCheck() throws
Exception {
+ java.nio.file.Path localDir = tempDir.resolve("commit-out");
+ java.nio.file.Files.createDirectories(localDir);
+ java.nio.file.Files.writeString(localDir.resolve("part-00000.csv"), "a,b");
+ java.nio.file.Files.writeString(localDir.resolve(".part-00000.csv.crc"),
"crc");
+
+ // The three-slash form is what a resolved VFS variable looks like
(file:///C:/... on Windows).
+ String fileUri = localDir.toUri().toString();
+ assertTrue(fileUri.startsWith("file:///"), fileUri);
+ Path dir = new Path(fileUri);
+
+ HopWindowsLocalFileSystem fs = new HopWindowsLocalFileSystem(new
JavaDirectoryListing());
+ try {
+ fs.initialize(URI.create("file:///"), new Configuration());
+ FileStatus[] listed = fs.listStatus(dir);
+ Set<String> names =
+ Arrays.stream(listed)
+ .map(status -> status.getPath().getName())
+ .collect(Collectors.toSet());
+ assertEquals(Set.of("part-00000.csv"), names);
+
+ FileStatus[] oneFile = fs.listStatus(new Path(dir, "part-00000.csv"));
+ assertEquals(1, oneFile.length);
+ assertEquals("part-00000.csv", oneFile[0].getPath().getName());
+
+ assertThrows(
+ java.io.FileNotFoundException.class, () -> fs.listStatus(new
Path(dir, "missing")));
+ } finally {
+ fs.close();
+ }
+ }
+
+ @Test
+ void javaDirectoryListingRefusesAnUnreadableDirectory() throws Exception {
+ File directory = tempDir.resolve("hidden").toFile();
+ assertTrue(directory.mkdir());
+ directory.setReadable(false);
+ try {
+ Assumptions.assumeFalse(directory.canRead(), "this user can still read
the directory");
+ AccessDeniedException denied =
+ assertThrows(
+ AccessDeniedException.class,
+ () ->
+
HopWindowsLocalFileSystem.WinutilsFreeRawLocalFileSystem.listLocalNames(
+ directory));
+ assertTrue(denied.getMessage().contains(directory.toString()));
+ } finally {
+ directory.setReadable(true);
+ }
+ }
+
+ /** Raw file system that always takes the Windows listing path, including on
Linux. */
+ private static final class JavaDirectoryListing
+ extends HopWindowsLocalFileSystem.WinutilsFreeRawLocalFileSystem {
+
+ @Override
+ boolean skipNativeDirectoryListing() {
+ return true;
+ }
+ }
+
private static String read(HopWindowsLocalFileSystem fs, Path file) throws
Exception {
byte[] buf = new byte[5];
try (FSDataInputStream in = fs.open(file)) {