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

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


The following commit(s) were added to refs/heads/camel-4.18.x by this push:
     new d1321e01217d [camel-4.18.x] CAMEL-26066: camel-jsch - the scp producer 
reads the first acknowledgement of the remote scp, so it waits until the file 
is written (#29335)
d1321e01217d is described below

commit d1321e01217de2f962be3cd55de949e1394c9b12
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Nov 6 17:10:44 2026 +0100

    [camel-4.18.x] CAMEL-26066: camel-jsch - the scp producer reads the first 
acknowledgement of the remote scp, so it waits until the file is written 
(#29335)
    
    * CAMEL-26066: camel-jsch - the scp producer reads the first 
acknowledgement of the remote scp, so it waits until the file is written
    
    Co-authored-by: Claude <[email protected]>
---
 .../apache/camel/component/scp/ScpOperations.java  |  29 +++-
 .../scp/ScpProduceAcknowledgementTest.java         | 178 +++++++++++++++++++++
 2 files changed, 202 insertions(+), 5 deletions(-)

diff --git 
a/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
 
b/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
index 6d647eb263f8..69749681d428 100644
--- 
a/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
+++ 
b/components/camel-jsch/src/main/java/org/apache/camel/component/scp/ScpOperations.java
@@ -128,16 +128,12 @@ public class ScpOperations implements 
RemoteFileOperations<ScpFile> {
         try {
             channel = (ChannelExec) session.openChannel("exec");
             channel.setCommand(getScpCommand(cfg, file));
-            channel.connect(timeout);
-            if (LOG.isTraceEnabled()) {
-                LOG.trace("Channel connected to {}", 
cfg.remoteServerInformation());
-            }
 
             try {
                 if (is == null) {
                     is = exchange.getIn().getMandatoryBody(InputStream.class);
                 }
-                write(channel, file, is, cfg);
+                connectAndWrite(channel, file, is, cfg, timeout);
             } catch (InvalidPayloadException e) {
                 throw new GenericFileOperationFailedException("Cannot store 
file: " + name, e);
             } catch (IOException e) {
@@ -438,6 +434,29 @@ public class ScpOperations implements 
RemoteFileOperations<ScpFile> {
         return session;
     }
 
+    /**
+     * Connects the channel and writes the file. The remote scp sends a first 
acknowledgement as soon as it starts,
+     * before it reads the first protocol line, and JSch drops what the 
channel receives before its input stream is
+     * taken. So the streams are taken before the channel is connected, and 
that first acknowledgement is read before
+     * the first protocol line is sent. Then each acknowledgement read is the 
answer to the line or data just sent, and
+     * the last one is sent by the remote scp once it has written the file.
+     */
+    private void connectAndWrite(ChannelExec c, String name, InputStream data, 
ScpConfiguration cfg, int timeout)
+            throws IOException, JSchException {
+        OutputStream os = c.getOutputStream();
+        InputStream is = c.getInputStream();
+        try {
+            c.connect(timeout);
+            if (LOG.isTraceEnabled()) {
+                LOG.trace("Channel connected to {}", 
cfg.remoteServerInformation());
+            }
+            readAck(is);
+            writeFile(name, data, os, is, cfg);
+        } finally {
+            IOHelper.close(is, os);
+        }
+    }
+
     private void write(ChannelExec c, String name, InputStream data, 
ScpConfiguration cfg) throws IOException {
         OutputStream os = c.getOutputStream();
         InputStream is = c.getInputStream();
diff --git 
a/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpProduceAcknowledgementTest.java
 
b/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpProduceAcknowledgementTest.java
new file mode 100644
index 000000000000..bdfd7efd0749
--- /dev/null
+++ 
b/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpProduceAcknowledgementTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.scp;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.file.GenericFileOperationFailedException;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.scp.common.ScpTransferEventListener;
+import org.apache.sshd.scp.common.helpers.DefaultScpFileOpener;
+import org.apache.sshd.scp.server.ScpCommand;
+import org.apache.sshd.scp.server.ScpCommandFactory;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.command.Command;
+import org.junit.jupiter.params.ParameterizedTest;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * The remote scp acknowledges a file once it has written it. The producer 
waits for that acknowledgement, so the file
+ * is complete on the server when the exchange completes, and a file that the 
server cannot write fails the exchange.
+ * <p/>
+ * The remote scp sends a first acknowledgement as soon as it starts, before 
it reads the first protocol line. Each test
+ * also runs against a server that starts the scp command late, so that this 
first acknowledgement arrives after the
+ * channel is connected.
+ */
+public class ScpProduceAcknowledgementTest extends ScpServerTestSupport {
+
+    private static final long SLOW_START_MILLIS = 500;
+
+    private volatile boolean slowStart;
+    private volatile boolean failOpen;
+    private volatile boolean failWrite;
+
+    public ScpProduceAcknowledgementTest() {
+        serverConfigurer = sshd -> {
+            ScpCommandFactory factory = new ScpCommandFactory() {
+                @Override
+                protected Command executeSupportedCommand(ChannelSession 
channel, String command) {
+                    return new ScpCommand(
+                            channel, command, resolveExecutorService(command), 
getSendBufferSize(),
+                            getReceiveBufferSize(), getScpFileOpener(), 
ScpTransferEventListener.EMPTY) {
+                        @Override
+                        public void start(ChannelSession channel, Environment 
env) throws IOException {
+                            if (!slowStart) {
+                                super.start(channel, env);
+                                return;
+                            }
+                            
CompletableFuture.delayedExecutor(SLOW_START_MILLIS, 
TimeUnit.MILLISECONDS).execute(() -> {
+                                try {
+                                    super.start(channel, env);
+                                } catch (IOException e) {
+                                    LOG.warn("Cannot start the scp command", 
e);
+                                }
+                            });
+                        }
+                    };
+                }
+            };
+            factory.setScpFileOpener(new DefaultScpFileOpener() {
+                @Override
+                public OutputStream openWrite(
+                        Session session, Path file, long size, 
Set<PosixFilePermission> permissions, OpenOption... options)
+                        throws IOException {
+                    if (failOpen) {
+                        throw new IOException("Permission denied");
+                    }
+                    OutputStream os = super.openWrite(session, file, size, 
permissions, options);
+                    if (!failWrite) {
+                        return os;
+                    }
+                    return new FilterOutputStream(os) {
+                        @Override
+                        public void write(int b) throws IOException {
+                            throw new IOException("No space left on device");
+                        }
+
+                        @Override
+                        public void write(byte[] b, int off, int len) throws 
IOException {
+                            throw new IOException("No space left on device");
+                        }
+                    };
+                }
+            });
+            sshd.setCommandFactory(factory);
+        };
+    }
+
+    @ParameterizedTest(name = "slowStart={0}")
+    @ValueSource(booleans = { false, true })
+    public void testFileIsCompleteWhenTheExchangeCompletes(boolean slowStart) 
throws Exception {
+        assumeTrue(isSetupComplete());
+        this.slowStart = slowStart;
+
+        template.sendBodyAndHeader(getProducerUri(), "Hello World", 
Exchange.FILE_NAME, "hello.txt");
+
+        assertEquals("Hello World", Files.readString(Paths.get(getScpPath(), 
"hello.txt")));
+    }
+
+    @ParameterizedTest(name = "slowStart={0}")
+    @ValueSource(booleans = { false, true })
+    public void testNestedFileIsCompleteWhenTheExchangeCompletes(boolean 
slowStart) throws Exception {
+        assumeTrue(isSetupComplete());
+        this.slowStart = slowStart;
+
+        template.sendBodyAndHeader(getProducerUri(), "Hello World", 
Exchange.FILE_NAME, "mysub/mysubsub/hello.txt");
+
+        assertEquals("Hello World", Files.readString(Paths.get(getScpPath(), 
"mysub/mysubsub/hello.txt")));
+    }
+
+    @ParameterizedTest(name = "slowStart={0}")
+    @ValueSource(booleans = { false, true })
+    public void testFileTheServerCannotWriteFailsTheExchange(boolean 
slowStart) {
+        assumeTrue(isSetupComplete());
+        this.slowStart = slowStart;
+        failWrite = true;
+
+        assertServerError("No space left on device");
+    }
+
+    @ParameterizedTest(name = "slowStart={0}")
+    @ValueSource(booleans = { false, true })
+    public void testFileTheServerCannotCreateFailsTheExchange(boolean 
slowStart) {
+        assumeTrue(isSetupComplete());
+        this.slowStart = slowStart;
+        failOpen = true;
+
+        assertServerError("Permission denied");
+        assertFalse(Files.exists(Paths.get(getScpPath(), "hello.txt")), "the 
file is not created");
+    }
+
+    private void assertServerError(String message) {
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.sendBodyAndHeader(getProducerUri(), "Hello 
World", Exchange.FILE_NAME, "hello.txt"));
+
+        GenericFileOperationFailedException cause
+                = assertInstanceOf(GenericFileOperationFailedException.class, 
e.getCause());
+        IOException scpError = assertInstanceOf(IOException.class, 
cause.getCause());
+        assertTrue(scpError.getMessage().contains(message), 
scpError.getMessage());
+    }
+
+    private String getProducerUri() {
+        return getScpUri() + "?username=admin&password=admin&knownHostsFile=" 
+ getKnownHostsFile();
+    }
+}

Reply via email to