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 9875b48005 minor improvements to vfs (test & docs), fixes #7148 (#7149)
9875b48005 is described below

commit 9875b480050c4711284825486f190b1a6473a58f
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Wed May 20 21:55:47 2026 +0200

    minor improvements to vfs (test & docs), fixes #7148 (#7149)
---
 core/pom.xml                                       |  21 ++
 .../main/java/org/apache/hop/core/vfs/HopVfs.java  |   5 +-
 .../hop/core/vfs/HopVfsNetworkProvidersTest.java   | 346 +++++++++++++++++++++
 .../apache/hop/core/vfs/HopVfsProvidersTest.java   | 257 +++++++++++++++
 docs/hop-user-manual/modules/ROOT/pages/vfs.adoc   |  90 +++---
 5 files changed, 664 insertions(+), 55 deletions(-)

diff --git a/core/pom.xml b/core/pom.xml
index afe26470d8..2919637317 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -462,6 +462,27 @@
             <scope>test</scope>
         </dependency>
 
+        <!-- Embedded servers used by HopVfsNetworkProvidersTest to verify the 
FTP/FTPS/SFTP
+             VFS providers actually exchange bytes. Test scope only. -->
+        <dependency>
+            <groupId>org.apache.ftpserver</groupId>
+            <artifactId>ftpserver-core</artifactId>
+            <version>1.2.1</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sshd</groupId>
+            <artifactId>sshd-core</artifactId>
+            <version>2.16.0</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sshd</groupId>
+            <artifactId>sshd-sftp</artifactId>
+            <version>2.16.0</version>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>org.javassist</groupId>
             <artifactId>javassist</artifactId>
diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
index 71c48bbde1..7f94a04370 100644
--- a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
@@ -166,7 +166,10 @@ public class HopVfs {
       fsm.addMimeTypeMap("application/x-gzip", "gz");
       fsm.addMimeTypeMap("application/zip", "zip");
       fsm.setFileContentInfoFactory(new FileContentInfoFilenameFactory());
-      fsm.setReplicator(new DefaultFileReplicator());
+
+      DefaultFileReplicator replicator = new DefaultFileReplicator();
+      fsm.setReplicator(replicator);
+      fsm.setTemporaryFileStore(replicator);
 
       fsm.setFilesCache(new SoftRefFilesCache());
       fsm.setCacheStrategy(CacheStrategy.ON_RESOLVE);
diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNetworkProvidersTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNetworkProvidersTest.java
new file mode 100644
index 0000000000..928730fba1
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNetworkProvidersTest.java
@@ -0,0 +1,346 @@
+/*
+ * 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.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.sun.net.httpserver.HttpServer;
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsServer;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.util.Collections;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.FileSystemOptions;
+import org.apache.commons.vfs2.provider.ftp.FtpFileSystemConfigBuilder;
+import org.apache.commons.vfs2.provider.ftps.FtpsDataChannelProtectionLevel;
+import org.apache.commons.vfs2.provider.ftps.FtpsFileSystemConfigBuilder;
+import org.apache.ftpserver.FtpServer;
+import org.apache.ftpserver.FtpServerFactory;
+import org.apache.ftpserver.listener.Listener;
+import org.apache.ftpserver.listener.ListenerFactory;
+import org.apache.ftpserver.ssl.SslConfigurationFactory;
+import org.apache.ftpserver.usermanager.PropertiesUserManagerFactory;
+import org.apache.ftpserver.usermanager.impl.BaseUser;
+import org.apache.ftpserver.usermanager.impl.WritePermission;
+import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.auth.password.AcceptAllPasswordAuthenticator;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.apache.sshd.sftp.server.SftpSubsystemFactory;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Round-trip tests for the network VFS providers Hop registers by default: 
{@code http}, {@code
+ * https}, {@code ftp}, {@code ftps}, {@code sftp}. Each is exercised against 
an embedded server so
+ * we don't need external resources.
+ */
+class HopVfsNetworkProvidersTest {
+
+  private static final String KEYSTORE_PASSWORD = "hop-test-pass";
+  private static final String FTP_USER = "tester";
+  private static final String FTP_PASS = "secret";
+  private static final String SFTP_USER = "alice";
+  private static final String SFTP_PASS = "secret";
+
+  @TempDir static Path sharedRoot;
+
+  private static HttpServer httpServer;
+  private static int httpPort;
+
+  private static HttpsServer httpsServer;
+  private static int httpsPort;
+  private static Path keyStorePath;
+  private static String previousTrustStoreProperty;
+  private static String previousTrustStorePasswordProperty;
+
+  private static FtpServer ftpServer;
+  private static Listener ftpListener;
+  private static int ftpPort;
+
+  private static FtpServer ftpsServer;
+  private static Listener ftpsListener;
+  private static int ftpsPort;
+
+  private static SshServer sshServer;
+  private static int sftpPort;
+
+  @BeforeAll
+  static void startServers() throws Exception {
+    keyStorePath = generateTestKeyStore();
+
+    // HTTP
+    httpServer = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+    httpPort = httpServer.getAddress().getPort();
+    httpServer.createContext("/payload.txt", new 
FixedPayloadHandler("http-payload"));
+    httpServer.start();
+
+    // HTTPS
+    SSLContext sslContext = buildServerSslContext(keyStorePath);
+    httpsServer = HttpsServer.create(new InetSocketAddress("localhost", 0), 0);
+    httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslContext));
+    httpsServer.createContext("/secure.txt", new 
FixedPayloadHandler("https-payload"));
+    httpsServer.start();
+    httpsPort = httpsServer.getAddress().getPort();
+
+    // Make Hop's HTTPS provider trust the self-signed cert via the JVM-wide 
trust store.
+    previousTrustStoreProperty = 
System.getProperty("javax.net.ssl.trustStore");
+    previousTrustStorePasswordProperty = 
System.getProperty("javax.net.ssl.trustStorePassword");
+    System.setProperty("javax.net.ssl.trustStore", keyStorePath.toString());
+    System.setProperty("javax.net.ssl.trustStorePassword", KEYSTORE_PASSWORD);
+
+    // FTP
+    Path ftpHome = sharedRoot.resolve("ftp");
+    Files.createDirectories(ftpHome);
+    Files.writeString(ftpHome.resolve("greeting.txt"), "ftp-payload");
+    FtpServerStart ftp = startFtp(ftpHome, false);
+    ftpServer = ftp.server;
+    ftpListener = ftp.listener;
+    ftpPort = ftpListener.getPort();
+
+    // FTPS
+    Path ftpsHome = sharedRoot.resolve("ftps");
+    Files.createDirectories(ftpsHome);
+    Files.writeString(ftpsHome.resolve("greeting.txt"), "ftps-payload");
+    FtpServerStart ftps = startFtp(ftpsHome, true);
+    ftpsServer = ftps.server;
+    ftpsListener = ftps.listener;
+    ftpsPort = ftpsListener.getPort();
+
+    // SFTP
+    Path sftpHome = sharedRoot.resolve("sftp");
+    Files.createDirectories(sftpHome);
+    Files.writeString(sftpHome.resolve("greeting.txt"), "sftp-payload");
+    sshServer = startSftp(sftpHome);
+    sftpPort = sshServer.getPort();
+  }
+
+  @AfterAll
+  static void stopServers() throws Exception {
+    if (httpServer != null) httpServer.stop(0);
+    if (httpsServer != null) httpsServer.stop(0);
+    if (ftpServer != null) ftpServer.stop();
+    if (ftpsServer != null) ftpsServer.stop();
+    if (sshServer != null) sshServer.stop();
+    restoreSystemProperty("javax.net.ssl.trustStore", 
previousTrustStoreProperty);
+    restoreSystemProperty("javax.net.ssl.trustStorePassword", 
previousTrustStorePasswordProperty);
+    if (keyStorePath != null) Files.deleteIfExists(keyStorePath);
+  }
+
+  @Test
+  @DisplayName("http:// fetches a payload from an embedded HttpServer")
+  void httpProviderReadsFromEmbeddedServer() throws Exception {
+    assertEquals("http-payload", readToString("http://localhost:"; + httpPort + 
"/payload.txt"));
+  }
+
+  @Test
+  @DisplayName("https:// fetches a payload over TLS from an embedded 
HttpsServer")
+  void httpsProviderReadsFromEmbeddedServer() throws Exception {
+    assertEquals("https-payload", readToString("https://localhost:"; + 
httpsPort + "/secure.txt"));
+  }
+
+  @Test
+  @DisplayName("ftp:// fetches a payload from an embedded Apache FtpServer")
+  void ftpProviderReadsFromEmbeddedServer() throws Exception {
+    String url = "ftp://"; + FTP_USER + ":" + FTP_PASS + "@localhost:" + 
ftpPort + "/greeting.txt";
+    FileSystemOptions opts = new FileSystemOptions();
+    FtpFileSystemConfigBuilder.getInstance().setPassiveMode(opts, true);
+    assertEquals("ftp-payload", readWithOptions(url, opts));
+  }
+
+  @Test
+  @DisplayName("ftps:// fetches a payload over TLS from an embedded Apache 
FtpServer")
+  void ftpsProviderReadsFromEmbeddedServer() throws Exception {
+    String url = "ftps://" + FTP_USER + ":" + FTP_PASS + "@localhost:" + 
ftpsPort + "/greeting.txt";
+    FileSystemOptions opts = new FileSystemOptions();
+    FtpsFileSystemConfigBuilder ftps = 
FtpsFileSystemConfigBuilder.getInstance();
+    ftps.setPassiveMode(opts, true);
+    ftps.setDataChannelProtectionLevel(opts, FtpsDataChannelProtectionLevel.P);
+    assertEquals("ftps-payload", readWithOptions(url, opts));
+  }
+
+  @Test
+  @DisplayName("sftp:// fetches a payload from an embedded Apache MINA SSHD 
server")
+  void sftpProviderReadsFromEmbeddedServer() throws Exception {
+    String url =
+        "sftp://"; + SFTP_USER + ":" + SFTP_PASS + "@localhost:" + sftpPort + 
"/greeting.txt";
+    assertEquals("sftp-payload", readToString(url));
+  }
+
+  // --- helpers 
---------------------------------------------------------------------------
+
+  private static String readToString(String url) throws Exception {
+    try (InputStream in = 
HopVfs.getFileObject(url).getContent().getInputStream()) {
+      return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+    }
+  }
+
+  private static String readWithOptions(String url, FileSystemOptions opts) 
throws Exception {
+    FileObject obj = HopVfs.getFileSystemManager().resolveFile(url, opts);
+    try (InputStream in = obj.getContent().getInputStream()) {
+      return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+    }
+  }
+
+  /** Generates a fresh PKCS12 keystore containing a self-signed cert for 
CN=localhost. */
+  private static Path generateTestKeyStore() throws Exception {
+    Path path = Files.createTempFile("hopvfs-network-", ".p12");
+    Files.deleteIfExists(path);
+    String keytool = Path.of(System.getProperty("java.home"), "bin", 
"keytool").toString();
+    Process process =
+        new ProcessBuilder(
+                keytool,
+                "-genkeypair",
+                "-alias",
+                "hopvfs-test",
+                "-keyalg",
+                "RSA",
+                "-keysize",
+                "2048",
+                "-validity",
+                "1",
+                "-storetype",
+                "PKCS12",
+                "-keystore",
+                path.toString(),
+                "-storepass",
+                KEYSTORE_PASSWORD,
+                "-keypass",
+                KEYSTORE_PASSWORD,
+                "-dname",
+                "CN=localhost, OU=Hop, O=Apache, L=Test, S=Test, C=US",
+                "-ext",
+                "SAN=DNS:localhost,IP:127.0.0.1",
+                "-noprompt")
+            .redirectErrorStream(true)
+            .start();
+    int exit = process.waitFor();
+    if (exit != 0) {
+      String stderr = new String(process.getInputStream().readAllBytes(), 
StandardCharsets.UTF_8);
+      throw new IOException("keytool failed (exit " + exit + "): " + stderr);
+    }
+    return path;
+  }
+
+  private static SSLContext buildServerSslContext(Path ks) throws Exception {
+    KeyStore keyStore = KeyStore.getInstance("PKCS12");
+    try (InputStream in = Files.newInputStream(ks)) {
+      keyStore.load(in, KEYSTORE_PASSWORD.toCharArray());
+    }
+    KeyManagerFactory kmf = 
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+    kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray());
+
+    TrustManagerFactory tmf =
+        
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+    tmf.init(keyStore);
+
+    SSLContext ctx = SSLContext.getInstance("TLS");
+    ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
+    return ctx;
+  }
+
+  /** Holder so callers can recover the dynamic port from the {@link 
Listener}. */
+  private record FtpServerStart(FtpServer server, Listener listener) {}
+
+  private static FtpServerStart startFtp(Path home, boolean tls) throws 
Exception {
+    FtpServerFactory serverFactory = new FtpServerFactory();
+    ListenerFactory listenerFactory = new ListenerFactory();
+    listenerFactory.setPort(0);
+    if (tls) {
+      SslConfigurationFactory ssl = new SslConfigurationFactory();
+      ssl.setKeystoreFile(keyStorePath.toFile());
+      ssl.setKeystorePassword(KEYSTORE_PASSWORD);
+      ssl.setKeyPassword(KEYSTORE_PASSWORD);
+      listenerFactory.setSslConfiguration(ssl.createSslConfiguration());
+      // Explicit FTPS (AUTH TLS) — matches commons-vfs2's default 
FtpsMode.EXPLICIT.
+      listenerFactory.setImplicitSsl(false);
+    }
+    Listener listener = listenerFactory.createListener();
+    serverFactory.addListener("default", listener);
+
+    PropertiesUserManagerFactory userManagerFactory = new 
PropertiesUserManagerFactory();
+    BaseUser user = new BaseUser();
+    user.setName(FTP_USER);
+    user.setPassword(FTP_PASS);
+    user.setHomeDirectory(home.toString());
+    user.setAuthorities(Collections.singletonList(new WritePermission()));
+    serverFactory.setUserManager(userManagerFactory.createUserManager());
+    serverFactory.getUserManager().save(user);
+
+    FtpServer server = serverFactory.createServer();
+    server.start();
+    return new FtpServerStart(server, listener);
+  }
+
+  private static SshServer startSftp(Path home) throws IOException {
+    SshServer sshd = SshServer.setUpDefaultServer();
+    sshd.setHost("localhost");
+    sshd.setPort(0);
+    sshd.setKeyPairProvider(new 
SimpleGeneratorHostKeyProvider(sharedRoot.resolve("hostkey.ser")));
+    sshd.setPasswordAuthenticator(AcceptAllPasswordAuthenticator.INSTANCE);
+    sshd.setFileSystemFactory(new VirtualFileSystemFactory(home));
+    sshd.setSubsystemFactories(Collections.singletonList(new 
SftpSubsystemFactory()));
+    // commons-vfs2's SFTP client doesn't actively close the session when the 
FileObject
+    // is closed; the server's default 10-minute IDLE_TIMEOUT would otherwise 
hold the
+    // test open. Drop it to a few seconds so the read returns promptly.
+    org.apache.sshd.core.CoreModuleProperties.IDLE_TIMEOUT.set(
+        sshd, java.time.Duration.ofSeconds(5));
+    sshd.start();
+    return sshd;
+  }
+
+  private static void restoreSystemProperty(String key, String previousValue) {
+    if (previousValue == null) {
+      System.clearProperty(key);
+    } else {
+      System.setProperty(key, previousValue);
+    }
+  }
+
+  /** Sends a fixed string body with a 200 response. */
+  private static final class FixedPayloadHandler implements 
com.sun.net.httpserver.HttpHandler {
+    private final byte[] body;
+
+    FixedPayloadHandler(String body) {
+      this.body = body.getBytes(StandardCharsets.UTF_8);
+    }
+
+    @Override
+    public void handle(com.sun.net.httpserver.HttpExchange exchange) throws 
IOException {
+      exchange.getResponseHeaders().add("Content-Type", "text/plain; 
charset=UTF-8");
+      exchange.sendResponseHeaders(200, body.length);
+      try (OutputStream out = exchange.getResponseBody()) {
+        out.write(body);
+      }
+      exchange.close();
+    }
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsProvidersTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsProvidersTest.java
new file mode 100644
index 0000000000..042a03b802
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsProvidersTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Coverage check for the VFS providers Hop registers by default in {@link 
HopVfs}. Smoke-tests
+ * every scheme is registered; for providers that don't need a network or 
credentials, also
+ * round-trips a real file to confirm the provider is functional and not just 
resolvable.
+ */
+class HopVfsProvidersTest {
+
+  /** Schemes registered unconditionally in {@code 
HopVfs.createFileSystemManager}. */
+  private static final List<String> BUILTIN_SCHEMES =
+      List.of(
+          "ram",
+          "file",
+          "res",
+          "zip",
+          "gz",
+          "jar",
+          "http",
+          "https",
+          "ftp",
+          "ftps",
+          "sftp",
+          "war",
+          "par",
+          "ear",
+          "sar",
+          "ejb3",
+          "tmp",
+          "tar",
+          "tbz2",
+          "tgz",
+          "bz2",
+          "files-cache");
+
+  @Test
+  @DisplayName("All built-in providers are registered")
+  void allBuiltinSchemesAreRegistered() {
+    DefaultFileSystemManager fsm = HopVfs.getFileSystemManager();
+    Set<String> registered = new HashSet<>(Arrays.asList(fsm.getSchemes()));
+
+    for (String scheme : BUILTIN_SCHEMES) {
+      assertTrue(
+          fsm.hasProvider(scheme),
+          "VFS provider missing for scheme '" + scheme + "'. Registered: " + 
registered);
+    }
+  }
+
+  @Test
+  @DisplayName("file:// round-trip: write, read back, exists, delete")
+  void fileProviderRoundTrips(@TempDir Path tmp) throws Exception {
+    Path path = tmp.resolve("hello.txt");
+    String url = path.toUri().toString();
+    writeBytes(url, "file-content".getBytes(StandardCharsets.UTF_8));
+    assertEquals("file-content", readString(url));
+    assertTrue(HopVfs.fileExists(url));
+    HopVfs.getFileObject(url).delete();
+  }
+
+  @Test
+  @DisplayName("ram:// round-trip")
+  void ramProviderRoundTrips() throws Exception {
+    String url = "ram:///" + uniqueName(".txt");
+    writeBytes(url, "ram-content".getBytes(StandardCharsets.UTF_8));
+    assertEquals("ram-content", readString(url));
+  }
+
+  @Test
+  @DisplayName("tmp:// round-trip")
+  void tmpProviderRoundTrips() throws Exception {
+    String url = "tmp:///" + uniqueName(".txt");
+    writeBytes(url, "tmp-content".getBytes(StandardCharsets.UTF_8));
+    assertEquals("tmp-content", readString(url));
+  }
+
+  @Test
+  @DisplayName("files-cache:// resolves to the same temp provider as tmp://")
+  void filesCacheProviderResolves() throws Exception {
+    String url = "files-cache:///" + uniqueName(".txt");
+    writeBytes(url, "cache".getBytes(StandardCharsets.UTF_8));
+    assertEquals("cache", readString(url));
+  }
+
+  @Test
+  @DisplayName("res:// reads a classpath resource")
+  void resProviderReadsClasspathResource() throws Exception {
+    // Use a resource that's guaranteed to be on the test classpath.
+    String url = "res:" + getClass().getName().replace('.', '/') + ".class";
+    FileObject obj = HopVfs.getFileObject(url);
+    assertTrue(obj.exists(), url + " should exist on the classpath");
+    assertTrue(obj.getContent().getSize() > 0);
+  }
+
+  @Test
+  @DisplayName("zip:// reads entries from a real zip file")
+  void zipProviderReadsEntries(@TempDir Path tmp) throws Exception {
+    Path archive = tmp.resolve("archive.zip");
+    try (java.util.zip.ZipOutputStream out =
+        new java.util.zip.ZipOutputStream(Files.newOutputStream(archive))) {
+      out.putNextEntry(new java.util.zip.ZipEntry("entry.txt"));
+      out.write("zip-entry".getBytes(StandardCharsets.UTF_8));
+      out.closeEntry();
+    }
+    String url = "zip:" + archive.toUri() + "!/entry.txt";
+    assertEquals("zip-entry", readString(url));
+  }
+
+  @Test
+  @DisplayName("jar:// reads entries the same way zip:// does")
+  void jarProviderReadsEntries(@TempDir Path tmp) throws Exception {
+    Path archive = tmp.resolve("archive.jar");
+    try (java.util.zip.ZipOutputStream out =
+        new java.util.zip.ZipOutputStream(Files.newOutputStream(archive))) {
+      out.putNextEntry(new java.util.zip.ZipEntry("payload.txt"));
+      out.write("jar-entry".getBytes(StandardCharsets.UTF_8));
+      out.closeEntry();
+    }
+    String url = "jar:" + archive.toUri() + "!/payload.txt";
+    assertEquals("jar-entry", readString(url));
+  }
+
+  @Test
+  @DisplayName("war/par/ear/sar/ejb3 share the jar provider")
+  void jarAliasesShareTheJarProvider(@TempDir Path tmp) throws Exception {
+    Path archive = tmp.resolve("archive.zip");
+    try (java.util.zip.ZipOutputStream out =
+        new java.util.zip.ZipOutputStream(Files.newOutputStream(archive))) {
+      out.putNextEntry(new java.util.zip.ZipEntry("inside.txt"));
+      out.write("hi".getBytes(StandardCharsets.UTF_8));
+      out.closeEntry();
+    }
+    for (String alias : new String[] {"war", "par", "ear", "sar", "ejb3"}) {
+      String url = alias + ":" + archive.toUri() + "!/inside.txt";
+      assertEquals("hi", readString(url), alias + " should read the entry");
+    }
+  }
+
+  @Test
+  @DisplayName("gz:// decompresses a gzipped file")
+  void gzProviderDecompresses(@TempDir Path tmp) throws Exception {
+    Path gz = tmp.resolve("payload.gz");
+    byte[] payload = "gzip-payload".getBytes(StandardCharsets.UTF_8);
+    try (java.util.zip.GZIPOutputStream out =
+        new java.util.zip.GZIPOutputStream(Files.newOutputStream(gz))) {
+      out.write(payload);
+    }
+    String url = "gz:" + gz.toUri();
+    try (InputStream in = 
HopVfs.getFileObject(url).getContent().getInputStream()) {
+      assertArrayEquals(payload, in.readAllBytes());
+    }
+  }
+
+  @Test
+  @DisplayName("bz2:// decompresses a bzip2 file")
+  void bz2ProviderDecompresses(@TempDir Path tmp) throws Exception {
+    Path bz2 = tmp.resolve("payload.bz2");
+    byte[] payload = "bz2-payload".getBytes(StandardCharsets.UTF_8);
+    try 
(org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream out =
+        new 
org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream(
+            Files.newOutputStream(bz2))) {
+      out.write(payload);
+    }
+    String url = "bz2:" + bz2.toUri();
+    try (InputStream in = 
HopVfs.getFileObject(url).getContent().getInputStream()) {
+      assertArrayEquals(payload, in.readAllBytes());
+    }
+  }
+
+  @Test
+  @DisplayName("tar:// reads entries from a tar archive")
+  void tarProviderReadsEntries(@TempDir Path tmp) throws Exception {
+    Path tar = tmp.resolve("archive.tar");
+    try (org.apache.commons.compress.archivers.tar.TarArchiveOutputStream out =
+        new org.apache.commons.compress.archivers.tar.TarArchiveOutputStream(
+            Files.newOutputStream(tar))) {
+      byte[] payload = "tar-entry".getBytes(StandardCharsets.UTF_8);
+      org.apache.commons.compress.archivers.tar.TarArchiveEntry entry =
+          new 
org.apache.commons.compress.archivers.tar.TarArchiveEntry("entry.txt");
+      entry.setSize(payload.length);
+      out.putArchiveEntry(entry);
+      out.write(payload);
+      out.closeArchiveEntry();
+    }
+    String url = "tar:" + tar.toUri() + "!/entry.txt";
+    assertEquals("tar-entry", readString(url));
+  }
+
+  @Test
+  @DisplayName(
+      "Remaining network providers are registered (round-trip in 
HopVfsNetworkProvidersTest)")
+  void remainingNetworkProvidersAreRegistered() {
+    // http/https/ftp/ftps/sftp need real servers; HopVfsNetworkProvidersTest 
spins up embedded
+    // ones for those. This case just checks they're present (the round-trip 
tests would fail
+    // earlier if they weren't).
+    DefaultFileSystemManager fsm = HopVfs.getFileSystemManager();
+    for (String scheme : new String[] {"http", "https", "ftp", "ftps", 
"sftp"}) {
+      assertTrue(fsm.hasProvider(scheme), scheme + " provider must be 
registered");
+    }
+  }
+
+  // --- helpers 
-----------------------------------------------------------------------------
+
+  private static String uniqueName(String suffix) {
+    return "hopvfs-test-" + System.nanoTime() + suffix;
+  }
+
+  private static void writeBytes(String url, byte[] bytes) throws Exception {
+    FileObject obj = HopVfs.getFileObject(url);
+    try (OutputStream out = obj.getContent().getOutputStream()) {
+      out.write(bytes);
+    }
+  }
+
+  private static String readString(String url) throws Exception {
+    try (InputStream in = 
HopVfs.getFileObject(url).getContent().getInputStream()) {
+      return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+    } catch (IOException e) {
+      throw e;
+    }
+  }
+}
diff --git a/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc
index 846ff157da..91acb32492 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc
@@ -38,12 +38,12 @@ Click the File system name to access more detailed file 
system documentation.
 |===
 |File System|Description|URI Format
 |xref:vfs/aws-s3-vfs.adoc[AWS S3]|Provides access to Amazon S3 Buckets|`s3://`
-|xref:vfs/azure-blob-storage-vfs.adoc[Azure Blob Storage]|Provides access to 
Azure Blob Storage|`azure://`
+|xref:vfs/azure-blob-storage-vfs.adoc[Azure Blob Storage]|Provides access to 
Azure Blob Storage|`azure://` (alias: `azfs://`)
 |xref:vfs/dropbox-vfs.adoc[Dropbox]|Provides access to Dropbox|`dropbox://`
 |xref:vfs/google-cloud-storage-vfs.adoc[Google Cloud Storage]|Provides access 
to Google Cloud Storage buckets|`gs://`
 |xref:vfs/google-drive-vfs.adoc[Google Drive]|Provides access to Google Drive 
folders|`googledrive://`
-|xref:metadata-types/minio-connection.adoc[Minio connection]|Provides access 
to S3 endpoints using a Minio client|`any://`
-|xref:metadata-types/webdav-connection.adoc[WebDAV Connection]|Provides access 
to WebDAV servers via a named connection (metadata)|`+connectionName://+`
+|xref:metadata-types/minio-connection.adoc[Minio connection]|Provides access 
to S3-compatible endpoints via a named Minio connection 
(metadata)|`+<connectionName>://+`
+|xref:metadata-types/webdav-connection.adoc[WebDAV Connection]|Provides access 
to WebDAV servers, either via the static schemes or a named connection 
(metadata)|`webdav4://`, `webdav4s://`, or `+<connectionName>://+`
 |===
 
 == Apache VFS File System Types
@@ -72,10 +72,6 @@ Examples
 
 * `+gz:/my/gz/file.gz+`
 
-//
-// CIFS
-//
-|CIFS*||
 |File|Provides access to the files on the local physical file system.
 a|URI Format
 
@@ -100,7 +96,7 @@ Examples
 |FTP|Provides access to the files on an FTP server.
 a|URI Format
 
-`+tp://[ username[: password]@] hostname[: port][ relative-path]+`
+`+ftp://[ username[: password]@] hostname[: port][ relative-path]+`
 
 Examples
 
@@ -127,21 +123,6 @@ Examples
 // GZIP
 //
 |GZIP|see 'bzip2'|
-//
-// HDFS
-//
-|HDFS|Provides access to files in an Apache Hadoop File System (HDFS).
-On Windows the integration test is disabled by default, as it requires 
binaries.
-a|
-URI Format
-
-`+hdfs:// hostname[: port][ absolute-path]+`
-
-Examples
-
-* `+hdfs://somehost:8080/downloads/some_dir+`
-* `+hdfs://somehost:8080/downloads/some_file.ext+`
-
 //
 // HTTP
 //
@@ -176,6 +157,7 @@ Examples
 // Jar, Zip and Tar
 //
 |Jar, Zip and Tar|Provides read-only access to the contents of Zip, Jar and 
Tar files.
+The `jar` provider is also registered under the `war`, `par`, `ear`, `sar`, 
and `ejb3` schemes for convenience when working with Java EE archive types.
 a|
 URI Format
 
@@ -202,23 +184,6 @@ Examples
 * `+tar:gz:http://anyhost/dir/mytar.tar.gz!/mytar.tar!/path/in/tar/README.txt+`
 * `+tgz:file://anyhost/dir/mytar.tgz!/somepath/somefile+`
 
-//
-// mime
-//
-|mime*|This (sandbox) filesystem can read mails and its attachements like 
archives.
-If a part in the parsed mail has no name, a dummy name will be generated.
-The dummy name is: _body_part_X where X will be replaced by the part number.
-a|
-URI Format
-
-`+mime:// mime-file-uri[! absolute-path]+`
-
-Examples
-
-* `+mime:file:///your/path/mail/anymail.mime!/+`
-* `+mime:file:///your/path/mail/anymail.mime!/filename.pdf+`
-* `+mime:file:///your/path/mail/anymail.mime!/_body_part_0+`
-
 //
 // RAM
 //
@@ -236,19 +201,6 @@ Examples
 
 * `+ram:///any/path/to/file.txt+`
 
-//
-// RES
-//
-|RES|This is not really a filesystem, it just tries to lookup a resource using 
javas ClassLoader.getResource() and creates a VFS url for further processing.
-a|
-URI Format
-
-`+res://[ path]+`
-
-Examples
-
-* `+res://path/in/classpath/image.png` might result in 
`jar:file://my/path/to/images.jar!/path/in/classpath/image.png+`
-
 //
 // SFTP
 //
@@ -311,4 +263,34 @@ Examples
 |Zip|see 'jar'|
 |===
 
-*) VFS file system type in development
+== Supported operations
+
+The matrix below shows which operations each registered provider exposes, 
taken from the capability set each provider declares in code.
+Capabilities can drift between commons-vfs2 releases, so the authoritative 
reference is the `capabilities` collection on each `FileProvider` / 
`FileSystem` class (in `commons-vfs2` for the standard providers, and in 
`plugins/tech/<plugin>` for the Hop-managed ones).
+A ✓ means the capability is declared by the provider; ✗ means it isn't.
+Server-side restrictions (read-only mounts, bucket policies, account 
permissions, ...) can still block individual operations at runtime.
+
+[options="header",cols="2,1,1,1,1,1,1,1,1,1"]
+|===
+|Scheme|Read|Write|Append|List|Create/Delete|Rename|Random read|Random 
write|Last-modified
+
+// Hop-managed providers
+|`s3`               |✓|✓|✗|✓|✓|✓|✓|✗|read
+|`azure`            |✓|✓|✗|✓|✓|✓|✗|✗|read
+|`gs`               |✓|✓|✗|✓|✓|✗|✗|✗|read/set
+|`googledrive`      |✓|✓|✗|✓|✓|✓|✓|✗|read
+|`dropbox`          |✓|✓|✗|✓|✓|✓|✗|✗|read
+|`+<minio-conn>+`   |✓|✓|✗|✓|✓|✓|✓|✗|read
+|`webdav4`, `webdav4s`, named WebDAV connection |✓|✓|✗|✓|✓|✓|✓|✗|read
+
+// Apache VFS providers
+|`file`, `tmp`, `files-cache` |✓|✓|✓|✓|✓|✓|✓|✓|read/set
+|`ram`              |✓|✓|✓|✓|✓|✓|✓|✓|read/set
+|`zip`              |✓|✗|✗|✓|✗|✗|✗|✗|read
+|`jar` (and `war`/`par`/`ear`/`sar`/`ejb3`) |✓|✗|✗|✓|✗|✗|✗|✗|read
+|`tar` (and `tbz2`/`tgz`) |✓|✗|✗|✓|✗|✗|✗|✗|read
+|`gz`, `bz2`        |✓|✓|✗|✓|✗|✗|✗|✗|read
+|`http`, `https`    |✓|✗|✗|✗|✗|✗|✓|✗|read
+|`ftp`, `ftps`      |✓|✓|✓|✓|✓|✓|✓|✗|read
+|`sftp`             |✓|✓|✓|✓|✓|✓|✓|✗|read/set
+|===

Reply via email to