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 713f5208b1a2 CAMEL-25948: camel-mina-sftp - delete the local work file 
of a consumed file when the exchange is done (#28466)
713f5208b1a2 is described below

commit 713f5208b1a2f7524d3d73fd7fc1d00c9a69d1b3
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 27 13:48:51 2026 +0530

    CAMEL-25948: camel-mina-sftp - delete the local work file of a consumed 
file when the exchange is done (#28466)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/catalog/docs/mina-sftp-component.adoc    |  72 ++++++++
 .../src/main/docs/mina-sftp-component.adoc         |  72 ++++++++
 .../file/remote/mina/MinaSftpOperations.java       |  10 ++
 .../MinaSftpConsumerLocalWorkDirectoryIT.java      | 183 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  11 ++
 5 files changed, 348 insertions(+)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
index c574abf5eb14..8f282d231c19 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
@@ -318,6 +318,78 @@ YAML::
 ----
 ====
 
+=== Using Local Work Directory
+
+Camel supports consuming from remote SFTP servers and downloading the
+files directly into a local work directory, with the `localWorkDirectory` 
option.
+This avoids reading the entire remote file content into memory as it is
+streamed directly into the local file using `FileOutputStream`.
+
+Camel will store to a local file with the same name as the remote file,
+though with `.inprogress` as the extension while the file is being
+downloaded. Afterward, the file is renamed to remove the `.inprogress`
+suffix. And finally, when the Exchange is done (also when it failed),
+the local file is deleted. If the download itself fails, the partly
+written `.inprogress` file is deleted at once.
+
+So if you want to download files from a remote SFTP server and store them
+as files, then you need to route to a file endpoint such as:
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("mina-sftp://[email protected]/download?password=secret&localWorkDirectory=/tmp";)
+    .to("file:inbox");
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+  <from 
uri="mina-sftp://[email protected]/download?password=secret&amp;localWorkDirectory=/tmp"/>
+  <to uri="file:inbox"/>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+    from:
+      uri: mina-sftp://[email protected]/download
+      parameters:
+        password: secret
+        localWorkDirectory: /tmp
+      steps:
+        - to:
+            uri: file:inbox
+----
+====
+
+The `java.io.File` handle of the local work file is the message body.
+The first file producer of the route uses a `java.io.File.rename` to move
+the local work file to its target, instead of a file copy, as the work file
+is meant to be deleted anyway.
+
+IMPORTANT: After that rename, the message body still names the local work 
file, which is gone.
+A later step that reads the file again fails, such as a second file producer 
or a `convertBodyTo`.
+Another exchange that keeps the `java.io.File` body, for example from a 
`wireTap` or an `aggregate`,
+finds the file deleted once the original exchange is done.
+Convert the body before such a step, for example with 
`convertBodyTo(byte[].class)`, which reads the file into memory.
+
+[source,java]
+----
+from("mina-sftp://[email protected]/download?password=secret&localWorkDirectory=/tmp";)
+    .convertBodyTo(byte[].class)
+    .to("file:inbox")
+    .to("file:archive");
+----
+
 == Error Handling
 
 === Connection Retry
diff --git a/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc 
b/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc
index c574abf5eb14..8f282d231c19 100644
--- a/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc
+++ b/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc
@@ -318,6 +318,78 @@ YAML::
 ----
 ====
 
+=== Using Local Work Directory
+
+Camel supports consuming from remote SFTP servers and downloading the
+files directly into a local work directory, with the `localWorkDirectory` 
option.
+This avoids reading the entire remote file content into memory as it is
+streamed directly into the local file using `FileOutputStream`.
+
+Camel will store to a local file with the same name as the remote file,
+though with `.inprogress` as the extension while the file is being
+downloaded. Afterward, the file is renamed to remove the `.inprogress`
+suffix. And finally, when the Exchange is done (also when it failed),
+the local file is deleted. If the download itself fails, the partly
+written `.inprogress` file is deleted at once.
+
+So if you want to download files from a remote SFTP server and store them
+as files, then you need to route to a file endpoint such as:
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("mina-sftp://[email protected]/download?password=secret&localWorkDirectory=/tmp";)
+    .to("file:inbox");
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+  <from 
uri="mina-sftp://[email protected]/download?password=secret&amp;localWorkDirectory=/tmp"/>
+  <to uri="file:inbox"/>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+    from:
+      uri: mina-sftp://[email protected]/download
+      parameters:
+        password: secret
+        localWorkDirectory: /tmp
+      steps:
+        - to:
+            uri: file:inbox
+----
+====
+
+The `java.io.File` handle of the local work file is the message body.
+The first file producer of the route uses a `java.io.File.rename` to move
+the local work file to its target, instead of a file copy, as the work file
+is meant to be deleted anyway.
+
+IMPORTANT: After that rename, the message body still names the local work 
file, which is gone.
+A later step that reads the file again fails, such as a second file producer 
or a `convertBodyTo`.
+Another exchange that keeps the `java.io.File` body, for example from a 
`wireTap` or an `aggregate`,
+finds the file deleted once the original exchange is done.
+Convert the body before such a step, for example with 
`convertBodyTo(byte[].class)`, which reads the file into memory.
+
+[source,java]
+----
+from("mina-sftp://[email protected]/download?password=secret&localWorkDirectory=/tmp";)
+    .convertBodyTo(byte[].class)
+    .to("file:inbox")
+    .to("file:archive");
+----
+
 == Error Handling
 
 === Connection Retry
diff --git 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
index dac17391bf29..7b055685059c 100644
--- 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
+++ 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
@@ -1265,9 +1265,19 @@ public class MinaSftpOperations implements 
RemoteFileOperations<SftpRemoteFile>
             // create parent folders
             temp.getParentFile().mkdirs();
 
+            // the consumer deletes the local work file named by this header 
when the exchange is done, and the file
+            // producer moves it to the target instead of copying it
+            exchange.getIn().setHeader(FtpConstants.FILE_LOCAL_WORK_PATH, 
local.getPath());
+
             try (InputStream is = sftpClient.read(name);
                  OutputStream os = new FileOutputStream(temp)) {
                 IOHelper.copy(is, os);
+            } catch (IOException e) {
+                // the streams are closed by now, so the partly written file 
can be deleted, as camel-sftp does
+                if (temp.exists() && !FileUtil.deleteFile(temp)) {
+                    LOG.warn("Cannot delete local work file: {} after 
retrieving file: {} failed", temp, name);
+                }
+                throw e;
             }
 
             LOG.debug("Retrieve file to local work file result: true");
diff --git 
a/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/integration/MinaSftpConsumerLocalWorkDirectoryIT.java
 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/integration/MinaSftpConsumerLocalWorkDirectoryIT.java
new file mode 100644
index 000000000000..8efe8f8d4eb7
--- /dev/null
+++ 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/integration/MinaSftpConsumerLocalWorkDirectoryIT.java
@@ -0,0 +1,183 @@
+/*
+ * 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.mina.integration;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.NotifyBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.TestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The local work file of a consumed file is deleted when the exchange is 
done, as with the sftp, ftp, smb and
+ * azure-files components.
+ */
+@EnabledIf(value = 
"org.apache.camel.test.infra.ftp.services.embedded.SftpUtil#hasRequiredAlgorithms('src/test/resources/hostkey.pem')")
+class MinaSftpConsumerLocalWorkDirectoryIT extends MinaSftpServerTestSupport {
+
+    @TempDir
+    Path testDirectory;
+
+    @Override
+    protected void setupResources() throws Exception {
+        service.getFtpRootDir().toFile().mkdirs();
+    }
+
+    private String consumerUri(String fileName) {
+        // a long delay so that a rolled back file is not consumed again while 
the test checks the local work directory
+        return "mina-sftp://localhost:"; + service.getPort() + "/" + 
service.getFtpRootDir()
+               + 
"?username=admin&password=admin&strictHostKeyChecking=no&useUserKnownHostsFile=false"
+               + "&fileName=" + fileName + 
"&noop=true&initialDelay=0&delay=600000"
+               + "&localWorkDirectory=" + testDirectory.resolve("lwd");
+    }
+
+    @Test
+    void localWorkFileIsDeletedWhenTheExchangeCompletes() throws Exception {
+        Files.writeString(ftpFile("hello.txt"), "Hello World");
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(consumerUri("hello.txt"))
+                        .process(this::assertBodyIsTheLocalWorkFile)
+                        .to("mock:result");
+            }
+
+            private void assertBodyIsTheLocalWorkFile(Exchange exchange) {
+                File body = exchange.getIn().getBody(File.class);
+                assertEquals(testDirectory.resolve("lwd/hello.txt").toFile(), 
body);
+                assertTrue(body.exists(), "The local work file should exist 
while the exchange is routed");
+            }
+        });
+
+        mock.assertIsSatisfied();
+
+        await().atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> 
assertFalse(Files.exists(testDirectory.resolve("lwd/hello.txt")),
+                        "The local work file should be deleted when the 
exchange is done"));
+    }
+
+    @Test
+    void localWorkFileIsMovedToTheTargetOfAFileProducer() throws Exception {
+        Files.writeString(ftpFile("moved.txt"), "Moved World");
+
+        NotifyBuilder completed = new 
NotifyBuilder(context).whenCompleted(1).create();
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(consumerUri("moved.txt"))
+                        .to(TestSupport.fileUri(testDirectory, "out"))
+                        .process(this::failIfTheLocalWorkFileIsLeft);
+            }
+
+            private void failIfTheLocalWorkFileIsLeft(Exchange exchange) {
+                // checked before the exchange is done, so before the consumer 
deletes the local work file: a file
+                // producer that copied it would leave it here, and the 
exchange fails instead of completing
+                if (Files.exists(testDirectory.resolve("lwd/moved.txt"))) {
+                    throw new IllegalStateException("The file producer should 
move the local work file, not copy it");
+                }
+            }
+        });
+
+        assertTrue(completed.matches(10, TimeUnit.SECONDS), "The exchange 
should complete");
+
+        assertEquals("Moved World", 
Files.readString(testDirectory.resolve("out/moved.txt")));
+    }
+
+    @Test
+    void aSecondFileProducerFindsTheLocalWorkFileMoved() throws Exception {
+        // as with camel-sftp: the first file producer moves the local work 
file, which the body still names
+        Files.writeString(ftpFile("twice.txt"), "Twice World");
+
+        NotifyBuilder failed = new 
NotifyBuilder(context).whenFailed(1).create();
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(consumerUri("twice.txt"))
+                        .to(TestSupport.fileUri(testDirectory, "first"))
+                        .to(TestSupport.fileUri(testDirectory, "second"));
+            }
+        });
+
+        assertTrue(failed.matches(10, TimeUnit.SECONDS), "The second file 
producer should fail");
+
+        assertEquals("Twice World", 
Files.readString(testDirectory.resolve("first/twice.txt")));
+        assertFalse(Files.exists(testDirectory.resolve("second/twice.txt")));
+    }
+
+    @Test
+    void aBodyConvertedBeforeTheFileProducerCanBeWrittenTwice() throws 
Exception {
+        // control, which passes without the fix too: the workaround of the 
upgrade guide
+        Files.writeString(ftpFile("converted.txt"), "Converted World");
+
+        NotifyBuilder done = new 
NotifyBuilder(context).whenCompleted(1).create();
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(consumerUri("converted.txt"))
+                        .convertBodyTo(byte[].class)
+                        .to(TestSupport.fileUri(testDirectory, "first"))
+                        .to(TestSupport.fileUri(testDirectory, "second"));
+            }
+        });
+
+        assertTrue(done.matches(10, TimeUnit.SECONDS), "The exchange should 
complete");
+
+        assertEquals("Converted World", 
Files.readString(testDirectory.resolve("first/converted.txt")));
+        assertEquals("Converted World", 
Files.readString(testDirectory.resolve("second/converted.txt")));
+    }
+
+    @Test
+    void localWorkFileIsDeletedWhenTheExchangeFails() throws Exception {
+        Files.writeString(ftpFile("bye.txt"), "Bye World");
+
+        NotifyBuilder failed = new 
NotifyBuilder(context).whenFailed(1).create();
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(consumerUri("bye.txt"))
+                        .throwException(new IllegalStateException("Forced"));
+            }
+        });
+
+        assertTrue(failed.matches(10, TimeUnit.SECONDS), "The exchange should 
fail");
+
+        await().atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> 
assertFalse(Files.exists(testDirectory.resolve("lwd/bye.txt")),
+                        "The local work file should be deleted when the 
exchange fails"));
+    }
+}
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 0be69c592ec1..b5233ff44e10 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
@@ -360,6 +360,17 @@ as the body, so `+${body}+` was the file found. A 
`fileName` that uses the file
 before. A `fileName` that read the file found through its headers, such as 
`${header.CamelFileName}`, must use the file
 functions instead (`${file:name}`).
 
+=== camel-mina-sftp - the local work file is deleted when the exchange is done
+
+With `localWorkDirectory`, camel-mina-sftp now deletes the local work file of 
a consumed file when the exchange is done,
+also when it failed, as camel-sftp and camel-ftp do. Before, every consumed 
file stayed in the local work directory. A
+file producer in the route now moves the local work file to its target instead 
of copying it, while the message body
+still names the local work file. So a later step that reads the body again, 
such as a second file producer or a
+`convertBodyTo`, fails because the file is gone, and another exchange that 
keeps the `java.io.File` body (for example
+`wireTap`, `seda` without waiting, or `aggregate`) finds it deleted. Convert 
the body before the first file producer
+or before handing it on, for example with `convertBodyTo(byte[].class)` (which 
reads the file into memory). Routes that
+cleaned the local work directory themselves can stop doing so.
+
 === camel-openstack - potential breaking change
 
 The four remaining Exchange header constants of `SwiftConstants` have been 
renamed to follow the Camel naming

Reply via email to