This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new f2c17c00443f CAMEL-26656: camel-sftp - check whether a file exists 
with one lstat instead of listing the directory (#29273)
f2c17c00443f is described below

commit f2c17c00443f56660be717e9988537573ecbb664
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Nov 6 17:08:24 2026 +0100

    CAMEL-26656: camel-sftp - check whether a file exists with one lstat 
instead of listing the directory (#29273)
    
    * CAMEL-26656: camel-sftp - check whether a file exists with one lstat 
instead of listing the directory
    * CAMEL-26656: camel-sftp - fastExistsCheck lists the directory when the 
server does not answer the lstat
    * CAMEL-26656: camel-sftp - list the directory on any lstat status other 
than no such file
    * CAMEL-26656: camel-sftp - say in the lstat comment that only the last 
separator is sent as '/'
    * CAMEL-26656: camel-sftp - rewrap the long line of the upgrade guide entry
    * CAMEL-26656: camel-sftp - getFileAttributes returns the size and 
modification time instead of the JSch SftpATTRS
    * CAMEL-26656: camel-sftp - test that a connection lost during the lstat 
fails the check
    
    Co-authored-by: Claude <[email protected]>
---
 .../apache/camel/catalog/docs/sftp-component.adoc  |  18 +
 .../camel-ftp/src/main/docs/sftp-component.adoc    |  18 +
 .../component/file/remote/SftpOperations.java      | 148 ++++++--
 .../strategy/SftpExclusiveReadLockCheck.java       |  46 ++-
 .../remote/sftp/integration/SftpExistsFileIT.java  | 421 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  20 +
 6 files changed, 622 insertions(+), 49 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
index 4c4e55ba2bff..c611cb9ac867 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
@@ -362,6 +362,24 @@ consumer is created, also with `poll` and `pollEnrich`, 
with `IllegalArgumentExc
 The `readLock` option is not checked when a custom `exclusiveReadLockStrategy` 
or `processStrategy`
 is set, as it does not apply then.
 
+=== Checking whether a file exists
+
+The `sftp` component checks whether a remote file exists with a single `lstat` 
request, whatever the number of files
+in the directory. This applies to `fileExist=Ignore`, `Fail` and `Move`, to 
`tempFileName` and `tempPrefix`, to a
+producer `doneFileName`, and to `readLock=changed`, which reads the size and 
modification time of the file with the same
+request on every check. So the `fastExistsCheck` option no longer makes a 
difference for these checks with `sftp`.
+
+The answer comes from the server for the path itself:
+
+* A symbolic link is not followed, as in a directory listing. A dangling link 
exists, and `readLock=changed` compares
+  the size and modification time of the link, not those of its target.
+* A server on a case-insensitive file system, such as one backed by Windows or 
macOS, finds a file whose name differs
+  only in case, which the upload would replace.
+
+When the server answers the request with another status than "no such file", 
for example because it does not permit or
+support it on the path, the component lists the directory instead and compares 
the names, as before. This listing is
+the same with and without `fastExistsCheck`.
+
 == More Information
 
 For more information, you can look at the xref:ftp-component.adoc[FTP 
component].
diff --git a/components/camel-ftp/src/main/docs/sftp-component.adoc 
b/components/camel-ftp/src/main/docs/sftp-component.adoc
index 4c4e55ba2bff..c611cb9ac867 100644
--- a/components/camel-ftp/src/main/docs/sftp-component.adoc
+++ b/components/camel-ftp/src/main/docs/sftp-component.adoc
@@ -362,6 +362,24 @@ consumer is created, also with `poll` and `pollEnrich`, 
with `IllegalArgumentExc
 The `readLock` option is not checked when a custom `exclusiveReadLockStrategy` 
or `processStrategy`
 is set, as it does not apply then.
 
+=== Checking whether a file exists
+
+The `sftp` component checks whether a remote file exists with a single `lstat` 
request, whatever the number of files
+in the directory. This applies to `fileExist=Ignore`, `Fail` and `Move`, to 
`tempFileName` and `tempPrefix`, to a
+producer `doneFileName`, and to `readLock=changed`, which reads the size and 
modification time of the file with the same
+request on every check. So the `fastExistsCheck` option no longer makes a 
difference for these checks with `sftp`.
+
+The answer comes from the server for the path itself:
+
+* A symbolic link is not followed, as in a directory listing. A dangling link 
exists, and `readLock=changed` compares
+  the size and modification time of the link, not those of its target.
+* A server on a case-insensitive file system, such as one backed by Windows or 
macOS, finds a file whose name differs
+  only in case, which the upload would replace.
+
+When the server answers the request with another status than "no such file", 
for example because it does not permit or
+support it on the path, the component lists the directory instead and compares 
the names, as before. This listing is
+the same with and without `fastExistsCheck`.
+
 == More Information
 
 For more information, you can look at the xref:ftp-component.adoc[FTP 
component].
diff --git 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
index bdc6887a6c04..d341e3892170 100644
--- 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
+++ 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
@@ -45,6 +45,7 @@ import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.JSchException;
 import com.jcraft.jsch.Proxy;
 import com.jcraft.jsch.Session;
+import com.jcraft.jsch.SftpATTRS;
 import com.jcraft.jsch.SftpException;
 import com.jcraft.jsch.SocketFactory;
 import com.jcraft.jsch.UIKeyboardInteractive;
@@ -1413,65 +1414,142 @@ public class SftpOperations
             if (endpoint.isFastExistsCheck()) {
                 return fastExistsFile(name);
             }
-            // check whether a file already exists
-            String directory = FileUtil.onlyPath(name);
-            if (directory == null) {
-                // assume current dir if no path could be extracted
-                directory = ".";
-            }
-            String onlyName = FileUtil.stripPath(name);
+            return getFileAttributes(name) != null;
+        } finally {
+            lock.unlock();
+        }
+    }
 
+    /**
+     * Gets the attributes of a file with a single request, whatever the 
number of files in its directory.
+     * <p/>
+     * These are the attributes of the path itself, as in a directory listing: 
a symbolic link is not followed, so for a
+     * link they are the size and modification time of the link, and a 
dangling link exists. When the server answers
+     * that request with another status than no such file, such as when it 
does not permit or support it on the path,
+     * the attributes are taken from a listing of the directory of the file 
instead.
+     *
+     * @param  name                                the name of the file, with 
its directory if any
+     * @return                                     the attributes, or {@code 
null} if there is no such file
+     * @throws GenericFileOperationFailedException if the attributes cannot be 
read
+     */
+    public FileAttributes getFileAttributes(String name) throws 
GenericFileOperationFailedException {
+        lock.lock();
+        try {
+            SftpATTRS attrs;
             try {
-                @SuppressWarnings("rawtypes")
-                List files = channel.ls(directory);
-                // can return either null or an empty list depending on FTP 
servers
-                if (files == null) {
-                    return false;
-                }
-                for (Object file : files) {
-                    ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) file;
-                    String existing = entry.getFilename();
-                    LOG.trace("Existing file: {}, target file: {}", existing, 
name);
-                    existing = FileUtil.stripPath(existing);
-                    if (existing != null && existing.equals(onlyName)) {
-                        return true;
-                    }
-                }
-                return false;
+                attrs = lstat(name);
             } catch (SftpException e) {
-                // or an exception can be thrown with id 2 which means file 
does not
-                // exists
-                if (ChannelSftp.SSH_FX_NO_SUCH_FILE == e.id) {
-                    return false;
+                if (!isLstatUnanswered(e)) {
+                    throw new 
GenericFileOperationFailedException(e.getMessage(), e);
                 }
-                // otherwise its a more serious error so rethrow
-                throw new GenericFileOperationFailedException(e.getMessage(), 
e);
+                LOG.debug("Cannot get the attributes of: {} due to: {}. 
Listing its directory instead.", name,
+                        e.getMessage());
+                attrs = listFileAttributes(name);
             }
+            return attrs != null ? new FileAttributes(attrs.getSize(), 
attrs.getMTime() * 1000L) : null;
         } finally {
             lock.unlock();
         }
     }
 
-    protected boolean fastExistsFile(String name) throws 
GenericFileOperationFailedException {
-        LOG.trace("fastExistsFile({})", name);
+    /**
+     * The attributes of a file read by {@link #getFileAttributes(String)}.
+     *
+     * @param fileLength   the file size
+     * @param lastModified the file modification timestamp (in millis)
+     */
+    public record FileAttributes(long fileLength, long lastModified) {
+    }
+
+    /**
+     * Gets the attributes of a file with a single LSTAT request.
+     *
+     * @param  name          the name of the file, with its directory if any
+     * @return               the attributes, or {@code null} if there is no 
such file
+     * @throws SftpException if the server answers with another status, or the 
request fails
+     */
+    private SftpATTRS lstat(String name) throws SftpException {
+        // the name starts after the last separator, either '/' or '\\', as in 
the store and in the listing of the
+        // directory, and is joined to the directory with '/'. The directory 
itself is sent as it is given
+        String directory = FileUtil.onlyPath(name);
+        String onlyName = FileUtil.stripPath(name);
+        String path;
+        if (directory == null) {
+            path = onlyName;
+        } else if (directory.endsWith("/")) {
+            path = directory + onlyName;
+        } else {
+            path = directory + "/" + onlyName;
+        }
+        try {
+            return channel.lstat(quotePathForJsch(path));
+        } catch (SftpException e) {
+            if (ChannelSftp.SSH_FX_NO_SUCH_FILE == e.id) {
+                return null;
+            }
+            throw e;
+        }
+    }
+
+    /**
+     * Whether the server answered an LSTAT request with a status that does 
not tell whether the path exists, so that
+     * the directory is listed instead, as before: any status other than no 
such file, which {@link #lstat(String)}
+     * already answers. A server may not permit or support the request on a 
path that it lists, answer with the generic
+     * failure for a path that does not exist, or send a status of a later 
version of the protocol.
+     */
+    private static boolean isLstatUnanswered(SftpException e) {
+        // JSch reports a status sent by the server as an SftpException 
without a cause, and wraps an I/O or session
+        // failure as an SftpException (SSH_FX_FAILURE) with that failure as 
its cause
+        return e.getCause() == null;
+    }
+
+    /**
+     * Gets the attributes of a file from a listing of its directory, 
comparing the names of the listed files.
+     */
+    private SftpATTRS listFileAttributes(String name) throws 
GenericFileOperationFailedException {
+        String directory = FileUtil.onlyPath(name);
+        if (directory == null) {
+            // assume current dir if no path could be extracted
+            directory = ".";
+        }
+        String onlyName = FileUtil.stripPath(name);
+
         try {
             @SuppressWarnings("rawtypes")
-            List files = channel.ls(name);
+            List files = channel.ls(directory);
+            // can return either null or an empty list depending on FTP servers
             if (files == null) {
-                return false;
+                return null;
+            }
+            for (Object file : files) {
+                ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) file;
+                String existing = entry.getFilename();
+                LOG.trace("Existing file: {}, target file: {}", existing, 
name);
+                existing = FileUtil.stripPath(existing);
+                if (existing != null && existing.equals(onlyName)) {
+                    return entry.getAttrs();
+                }
             }
-            return !files.isEmpty();
+            return null;
         } catch (SftpException e) {
             // or an exception can be thrown with id 2 which means file does 
not
             // exists
             if (ChannelSftp.SSH_FX_NO_SUCH_FILE == e.id) {
-                return false;
+                return null;
             }
             // otherwise its a more serious error so rethrow
             throw new GenericFileOperationFailedException(e.getMessage(), e);
         }
     }
 
+    protected boolean fastExistsFile(String name) throws 
GenericFileOperationFailedException {
+        LOG.trace("fastExistsFile({})", name);
+        // the same as without fastExistsCheck, also when the server does not 
answer the LSTAT: JSch ls(name) starts
+        // with a STAT of the path, which such a server would most likely not 
answer either, and then lists the whole
+        // directory of an existing file anyway
+        return getFileAttributes(name) != null;
+    }
+
     @Override
     public boolean sendNoop() throws GenericFileOperationFailedException {
         lock.lock();
diff --git 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/strategy/SftpExclusiveReadLockCheck.java
 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/strategy/SftpExclusiveReadLockCheck.java
index aef6ca884e3e..a6069c383ae6 100644
--- 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/strategy/SftpExclusiveReadLockCheck.java
+++ 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/strategy/SftpExclusiveReadLockCheck.java
@@ -20,6 +20,7 @@ package org.apache.camel.component.file.remote.strategy;
 import com.jcraft.jsch.ChannelSftp;
 import org.apache.camel.component.file.GenericFile;
 import org.apache.camel.component.file.GenericFileOperations;
+import org.apache.camel.component.file.remote.SftpOperations;
 import org.apache.camel.component.file.remote.SftpRemoteFile;
 import org.apache.camel.util.StopWatch;
 import org.slf4j.Logger;
@@ -52,21 +53,34 @@ public class SftpExclusiveReadLockCheck {
         long newLastModified = 0;
         long newLength = 0;
 
-        Object[] files = listFiles(operations, file);
-
-        LOG.trace("List files {} found {} files", file.getAbsoluteFilePath(), 
files.length);
-        for (Object f : files) {
-            SftpRemoteFile rf = (SftpRemoteFile) f;
-            boolean match;
-            if (fastExistsCheck) {
-                match = rf.getFilename().equals(file.getAbsoluteFilePath())
-                        || rf.getFilename().equals(file.getFileNameOnly());
-            } else {
-                match = rf.getFilename().equals(file.getFileNameOnly());
+        SftpOperations sftp = asSftpOperations(operations);
+        if (sftp != null) {
+            // a single request whatever the number of files in the directory. 
As in the directory listing, these are the
+            // attributes of the path itself: for a symbolic link, the size 
and modification time of the link, not of
+            // its target
+            SftpOperations.FileAttributes attrs = 
sftp.getFileAttributes(file.getAbsoluteFilePath());
+            LOG.trace("Attributes of {}: {}", file.getAbsoluteFilePath(), 
attrs);
+            if (attrs != null) {
+                newLastModified = attrs.lastModified();
+                newLength = attrs.fileLength();
             }
-            if (match) {
-                newLastModified = rf.getLastModified();
-                newLength = rf.getFileLength();
+        } else {
+            Object[] files = listFiles(operations, file);
+
+            LOG.trace("List files {} found {} files", 
file.getAbsoluteFilePath(), files.length);
+            for (Object f : files) {
+                SftpRemoteFile rf = (SftpRemoteFile) f;
+                boolean match;
+                if (fastExistsCheck) {
+                    match = rf.getFilename().equals(file.getAbsoluteFilePath())
+                            || rf.getFilename().equals(file.getFileNameOnly());
+                } else {
+                    match = rf.getFilename().equals(file.getFileNameOnly());
+                }
+                if (match) {
+                    newLastModified = rf.getLastModified();
+                    newLength = rf.getFileLength();
+                }
             }
         }
 
@@ -85,6 +99,10 @@ public class SftpExclusiveReadLockCheck {
         return false;
     }
 
+    private static SftpOperations asSftpOperations(GenericFileOperations<?> 
operations) {
+        return operations instanceof SftpOperations sftp ? sftp : null;
+    }
+
     private Object[] listFiles(
             GenericFileOperations<ChannelSftp.LsEntry> operations, 
GenericFile<ChannelSftp.LsEntry> file) {
         if (fastExistsCheck) {
diff --git 
a/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/sftp/integration/SftpExistsFileIT.java
 
b/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/sftp/integration/SftpExistsFileIT.java
new file mode 100644
index 000000000000..0993215b878a
--- /dev/null
+++ 
b/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/sftp/integration/SftpExistsFileIT.java
@@ -0,0 +1,421 @@
+/*
+ * 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.camel.component.file.remote.sftp.integration;
+
+import java.io.IOException;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileSystemException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.file.GenericFileOperationFailedException;
+import org.apache.camel.component.file.remote.SftpEndpoint;
+import org.apache.camel.component.file.remote.SftpOperations;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.infra.ftp.services.embedded.SftpEmbeddedService;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.util.function.ThrowingRunnable;
+import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.sftp.common.SftpConstants;
+import org.apache.sshd.sftp.common.SftpException;
+import org.apache.sshd.sftp.server.DirectoryHandle;
+import org.apache.sshd.sftp.server.SftpEventListener;
+import org.apache.sshd.sftp.server.SftpFileSystemAccessor;
+import org.apache.sshd.sftp.server.SftpSubsystemFactory;
+import org.apache.sshd.sftp.server.SftpSubsystemProxy;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The sftp component checks whether a file exists, and reads its size and 
modification time for readLock=changed, with
+ * a single LSTAT request, whatever the number of files in the directory 
(CAMEL-26656). It lists the directory only when
+ * the server does not answer that request for the path.
+ */
+@EnabledIf(value = 
"org.apache.camel.test.infra.ftp.services.embedded.SftpUtil#hasRequiredAlgorithms('src/test/resources/hostkey.pem')")
+class SftpExistsFileIT extends CamelTestSupport {
+
+    // a reply of the server to a directory listing request holds about 150 of 
these entries
+    private static final int FILES = 1000;
+
+    @RegisterExtension
+    CountingSftpEmbeddedService service = new CountingSftpEmbeddedService();
+
+    private Path base;
+
+    @BeforeEach
+    void createTestDirectory() throws IOException {
+        // the root directory of the server is shared by the tests of this 
class and kept between runs
+        base = 
Files.createTempDirectory(Files.createDirectories(service.getFtpRootDir()), 
"test");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void existsFileIsOneRequestWhateverTheDirectorySize(boolean 
fastExistsCheck) throws Exception {
+        createFiles(base.resolve("small"), 10);
+        createFiles(base.resolve("big"), FILES);
+        Files.createDirectories(base.resolve("big").resolve("sub"));
+
+        SftpOperations operations = connect(fastExistsCheck);
+        try {
+            for (String directory : new String[] { "small", "big" }) {
+                assertExists(operations, true, directory + "/file-0005.txt");
+                assertExists(operations, false, directory + "/missing.txt");
+            }
+            assertExists(operations, true, "big/sub");
+            assertExists(operations, false, "nodir/missing.txt");
+        } finally {
+            operations.disconnect();
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+            "PERMISSION_DENIED, false", "OP_UNSUPPORTED, false", "FAILURE, 
false", "BAD_MESSAGE, false",
+            "NO_SUCH_PATH, false",
+            "PERMISSION_DENIED, true", "OP_UNSUPPORTED, true", "FAILURE, 
true", "BAD_MESSAGE, true",
+            "NO_SUCH_PATH, true" })
+    void listsDirectoryWhenServerDoesNotAnswerLstat(String status, boolean 
fastExistsCheck) throws Exception {
+        createFiles(base.resolve("inbox"), 10);
+        service.onLstat(() -> {
+            switch (status) {
+                case "PERMISSION_DENIED" -> throw new 
AccessDeniedException("lstat");
+                case "OP_UNSUPPORTED" -> throw new 
UnsupportedOperationException("lstat");
+                // a status that does not say whether the path exists, and one 
of a later version of the protocol,
+                // which some servers send
+                case "BAD_MESSAGE" -> throw new 
SftpException(SftpConstants.SSH_FX_BAD_MESSAGE, "lstat");
+                case "NO_SUCH_PATH" -> throw new 
SftpException(SftpConstants.SSH_FX_NO_SUCH_PATH, "lstat");
+                default -> throw new IOException("lstat");
+            }
+        });
+
+        SftpOperations operations = connect(fastExistsCheck);
+        try {
+            service.reset();
+            
assertTrue(operations.existsFile(remotePath("inbox/file-0005.txt")));
+            
assertFalse(operations.existsFile(remotePath("inbox/missing.txt")));
+            assertEquals(2, service.requests(SftpConstants.SSH_FXP_LSTAT));
+            // the existing file was found in a listing of its directory, as 
before
+            assertTrue(service.requests(SftpConstants.SSH_FXP_OPENDIR) >= 1, 
"requests: " + service.requests);
+        } finally {
+            operations.disconnect();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void listsDirectoryWhenServerAnswersNeitherLstatNorStatOfFile(boolean 
fastExistsCheck) throws Exception {
+        createFiles(base.resolve("inbox"), 10);
+        // a server that does not permit LSTAT on a path does not permit STAT 
either, while it lists the directory
+        service.refuseAttributesOf(path -> !Files.isDirectory(path));
+
+        SftpOperations operations = connect(fastExistsCheck);
+        try {
+            service.reset();
+            
assertTrue(operations.existsFile(remotePath("inbox/file-0005.txt")));
+            
assertFalse(operations.existsFile(remotePath("inbox/missing.txt")));
+            assertEquals(2, service.requests(SftpConstants.SSH_FXP_LSTAT));
+            // both were looked up in a listing of their directory
+            assertEquals(2, service.requests(SftpConstants.SSH_FXP_OPENDIR), 
"requests: " + service.requests);
+        } finally {
+            operations.disconnect();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void failsWhenConnectionIsLostDuringLstat(boolean fastExistsCheck) throws 
Exception {
+        createFiles(base.resolve("inbox"), 10);
+
+        SftpOperations operations = connect(fastExistsCheck);
+        try {
+            service.reset();
+            // the server drops the connection when it receives the LSTAT 
request, before it answers
+            service.onLstat(service::disconnectAllSessions);
+            GenericFileOperationFailedException e = 
assertThrows(GenericFileOperationFailedException.class,
+                    () -> 
operations.existsFile(remotePath("inbox/file-0005.txt")));
+            assertEquals(1, service.requests(SftpConstants.SSH_FXP_LSTAT));
+            // the check fails, it does not answer that the file is missing. 
JSch gives the SftpException of this I/O
+            // failure a cause, and none to the SftpException of a status sent 
by the server, which tells them apart
+            assertNotNull(e.getCause().getCause(), "cause of: " + 
e.getCause());
+        } finally {
+            operations.disconnect();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void danglingSymbolicLinkExists(boolean fastExistsCheck) throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        createSymbolicLink(inbox.resolve("link.txt"), Path.of("missing.txt"));
+
+        SftpOperations operations = connect(fastExistsCheck);
+        try {
+            // the path itself, as in the directory listing: the link exists, 
though its target does not
+            assertExists(operations, true, "inbox/link.txt");
+            assertExists(operations, false, "inbox/missing.txt");
+        } finally {
+            operations.disconnect();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void fileExistFailDoesNotListDirectory(boolean fastExistsCheck) throws 
Exception {
+        Path out = base.resolve("out");
+        createFiles(out, FILES);
+        String uri = sftpUri("out") + 
"&autoCreate=false&fileExist=Fail&fastExistsCheck=" + fastExistsCheck;
+        // connect before counting
+        template.sendBodyAndHeader(uri, "first", Exchange.FILE_NAME, 
"first.txt");
+        service.reset();
+
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.sendBodyAndHeader(uri, "replaced", 
Exchange.FILE_NAME, "file-0005.txt"));
+        assertInstanceOf(GenericFileOperationFailedException.class, 
e.getCause());
+        template.sendBodyAndHeader(uri, "written", Exchange.FILE_NAME, 
"new.txt");
+
+        assertEquals("x", Files.readString(out.resolve("file-0005.txt")));
+        assertEquals("written", Files.readString(out.resolve("new.txt")));
+        assertEquals(2, service.requests(SftpConstants.SSH_FXP_LSTAT));
+        assertEquals(0, service.requests(SftpConstants.SSH_FXP_OPENDIR), 
"requests: " + service.requests);
+        assertEquals(0, service.entriesListed());
+    }
+
+    @Test
+    void tempFileNameReplacesExistingFileWithoutListingDirectory() throws 
Exception {
+        Path out = base.resolve("out");
+        createFiles(out, FILES);
+        String uri = sftpUri("out") + 
"&autoCreate=false&tempFileName=${file:onlyname}.inprogress";
+        // connect before counting
+        template.sendBodyAndHeader(uri, "first", Exchange.FILE_NAME, 
"first.txt");
+        service.reset();
+
+        template.sendBodyAndHeader(uri, "replaced", Exchange.FILE_NAME, 
"file-0005.txt");
+
+        assertEquals("replaced", 
Files.readString(out.resolve("file-0005.txt")));
+        assertFalse(Files.exists(out.resolve("file-0005.txt.inprogress")));
+        // the target and the temp file
+        assertEquals(2, service.requests(SftpConstants.SSH_FXP_LSTAT));
+        assertEquals(0, service.requests(SftpConstants.SSH_FXP_OPENDIR), 
"requests: " + service.requests);
+        assertEquals(0, service.entriesListed());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void readLockChangedDoesNotListDirectory(boolean fastExistsCheck) throws 
Exception {
+        createFiles(base.resolve("inbox"), FILES);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(1);
+
+        consume("inbox", 
"&maxMessagesPerPoll=1&readLock=changed&readLockCheckInterval=10&fastExistsCheck="
+                         + fastExistsCheck);
+
+        mock.assertIsSatisfied();
+        // at least two checks, with the same size and modification time
+        assertTrue(service.requests(SftpConstants.SSH_FXP_LSTAT) >= 2, 
"requests: " + service.requests);
+        // only the poll lists the directory, and stops at the first file
+        assertEquals(1, service.requests(SftpConstants.SSH_FXP_OPENDIR), 
"requests: " + service.requests);
+        assertTrue(service.entriesListed() < FILES, "entries listed: " + 
service.entriesListed());
+    }
+
+    @Test
+    void readLockChangedWaitsUntilFileStopsChanging() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path file = inbox.resolve("growing.txt");
+        Files.writeString(file, "line 0\n");
+        // the file grows before each of the first three checks reads its 
attributes
+        AtomicInteger checks = new AtomicInteger();
+        service.onLstat(() -> {
+            int check = checks.incrementAndGet();
+            if (check <= 3) {
+                Files.writeString(file, "line " + check + "\n", 
StandardOpenOption.APPEND);
+            }
+        });
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("line 0\nline 1\nline 2\nline 3\n");
+
+        consume("inbox", "&readLock=changed&readLockCheckInterval=10");
+
+        mock.assertIsSatisfied();
+        // the last two checks found the same size and modification time
+        assertTrue(checks.get() >= 4, "checks: " + checks.get());
+    }
+
+    private SftpOperations connect(boolean fastExistsCheck) {
+        SftpEndpoint endpoint = context.getEndpoint(sftpUri("") + 
"&fastExistsCheck=" + fastExistsCheck, SftpEndpoint.class);
+        SftpOperations operations = (SftpOperations) 
endpoint.createRemoteFileOperations();
+        operations.connect(endpoint.getConfiguration(), null);
+        // JSch asks the server for the home directory once, to resolve the 
relative paths against it
+        operations.getCurrentDirectory();
+        return operations;
+    }
+
+    private void assertExists(SftpOperations operations, boolean expected, 
String name) {
+        service.reset();
+        assertEquals(expected, operations.existsFile(remotePath(name)), name);
+        // a single LSTAT request, and no directory listing
+        assertEquals(1, service.requests(SftpConstants.SSH_FXP_LSTAT), name + 
", requests: " + service.requests);
+        assertEquals(1, service.requests(), name + ", requests: " + 
service.requests);
+        assertEquals(0, service.entriesListed(), name);
+    }
+
+    private String remotePath(String name) {
+        // relative to the home directory of the server, as the test directory
+        return base + "/" + name;
+    }
+
+    private String sftpUri(String directory) {
+        return "sftp://localhost:"; + service.getPort() + "/" + base + "/" + 
directory
+               + "?username=admin&password=admin&knownHostsFile=" + 
service.getKnownHostsFile();
+    }
+
+    private void consume(String directory, String query) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                // autoCreate checks that the directory exists by reading the 
first reply of a listing
+                from(sftpUri(directory) + 
"&autoCreate=false&initialDelay=0&delay=60000&noop=true" + query)
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+    }
+
+    private static void createFiles(Path directory, int count) throws 
IOException {
+        Files.createDirectories(directory);
+        for (int i = 0; i < count; i++) {
+            Files.writeString(directory.resolve(String.format("file-%04d.txt", 
i)), "x");
+        }
+    }
+
+    private static void createSymbolicLink(Path link, Path target) {
+        try {
+            Files.createSymbolicLink(link, target);
+        } catch (FileAlreadyExistsException e) {
+            throw new IllegalStateException(e);
+        } catch (UnsupportedOperationException | FileSystemException e) {
+            // such as Windows when the user may not create symbolic links
+            Assumptions.abort("Symbolic links cannot be created on this 
platform: " + e.getMessage());
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * The embedded SFTP server, counting the requests it receives and the 
directory entries it sends, and running a
+     * hook before it answers an LSTAT request: a hook that throws makes the 
server answer with an error status. It can
+     * also refuse the LSTAT and STAT requests on some paths only.
+     */
+    static class CountingSftpEmbeddedService extends SftpEmbeddedService {
+
+        // by the name of the request type, such as SSH_FXP_LSTAT
+        private final Map<String, AtomicInteger> requests = new 
ConcurrentHashMap<>();
+        private final AtomicInteger entriesListed = new AtomicInteger();
+        private volatile ThrowingRunnable<IOException> onLstat;
+        private volatile Predicate<Path> refuseAttributesOf;
+
+        @Override
+        public void setUpServer() throws Exception {
+            super.setUpServer();
+            SftpSubsystemFactory factory = (SftpSubsystemFactory) 
sshd.getSubsystemFactories().get(0);
+            factory.setFileSystemAccessor(new SftpFileSystemAccessor() {
+                @Override
+                public LinkOption[] resolveFileAccessLinkOptions(
+                        SftpSubsystemProxy subsystem, Path file, int cmd, 
String extension, boolean followLinks)
+                        throws IOException {
+                    Predicate<Path> refused = refuseAttributesOf;
+                    if ((cmd == SftpConstants.SSH_FXP_LSTAT || cmd == 
SftpConstants.SSH_FXP_STAT) && refused != null
+                            && refused.test(file)) {
+                        throw new AccessDeniedException(file.toString());
+                    }
+                    return 
SftpFileSystemAccessor.super.resolveFileAccessLinkOptions(
+                            subsystem, file, cmd, extension, followLinks);
+                }
+            });
+            factory.addSftpEventListener(new SftpEventListener() {
+                @Override
+                public void received(ServerSession session, int type, int id) 
throws IOException {
+                    
requests.computeIfAbsent(SftpConstants.getCommandMessageName(type), k -> new 
AtomicInteger())
+                            .incrementAndGet();
+                    ThrowingRunnable<IOException> hook = onLstat;
+                    if (type == SftpConstants.SSH_FXP_LSTAT && hook != null) {
+                        hook.run();
+                    }
+                }
+
+                @Override
+                public void readEntries(
+                        ServerSession session, String remoteHandle, 
DirectoryHandle localHandle, Map<String, Path> entries) {
+                    entriesListed.addAndGet(entries.size());
+                }
+            });
+        }
+
+        void onLstat(ThrowingRunnable<IOException> hook) {
+            this.onLstat = hook;
+        }
+
+        /**
+         * Answers the LSTAT and STAT requests on the paths that match with 
PERMISSION_DENIED.
+         */
+        void refuseAttributesOf(Predicate<Path> paths) {
+            this.refuseAttributesOf = paths;
+        }
+
+        void reset() {
+            requests.clear();
+            entriesListed.set(0);
+        }
+
+        int requests(int type) {
+            AtomicInteger count = 
requests.get(SftpConstants.getCommandMessageName(type));
+            return count != null ? count.get() : 0;
+        }
+
+        int requests() {
+            return 
requests.values().stream().mapToInt(AtomicInteger::get).sum();
+        }
+
+        int entriesListed() {
+            return entriesListed.get();
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index dbc8f371bfdd..db311e0e5996 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -163,6 +163,26 @@ Apache MINA SSHD leaves it off by default, so a small 
request, such as one that
 listing, could wait for the server to acknowledge the previous reply. The 
`org.apache.sshd.config.tcp-nodelay` system
 property no longer applies to the connections of `mina-sftp`.
 
+=== camel-ftp - sftp checks whether a file exists with one lstat request
+
+The `sftp` component now checks whether a remote file exists 
(`fileExist=Ignore|Fail|Move`, `tempFileName`,
+`tempPrefix`, a producer `doneFileName`), and reads the size and modification 
time of the file for `readLock=changed`,
+with a single `lstat` request instead of a listing of the directory. Each 
check is one request, whatever the number of
+files in the directory, and `fastExistsCheck` no longer makes a difference for 
these checks. The answer can differ from
+before in two cases:
+
+* A dangling symbolic link now exists also with `fastExistsCheck=true`, as it 
already did without that option: the
+  link itself is checked, not its target.
+* On a server with a case-insensitive file system, such as one backed by 
Windows or macOS, a file whose name differs
+  only in case now exists: before, the names of the listing were compared 
case-sensitively. For example, with
+  `fileExist=Fail`, sending `REPORT.TXT` to a directory that holds 
`report.txt` now fails, where before it replaced
+  `report.txt`.
+
+When the server answers `lstat` with another status than "no such file", for 
example because it does not permit or
+support it on the path, the component lists the directory as before. 
`readLock=changed` keeps comparing the size and
+modification time of a symbolic link itself, as the directory listing reported 
them. See the
+xref:components::sftp-component.adoc[SFTP component].
+
 === Java
 
 *Breaking change:* Camel 4.24 requires *Java 21* as the minimum runtime 
version.

Reply via email to