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 fdc1c0086a54 CAMEL-26672: Fix ScpOperations.storeFileDirectly to set
command and connect channel (#29317)
fdc1c0086a54 is described below
commit fdc1c0086a54610d16dd0765215ec3729599be56
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri Nov 6 21:38:24 2026 +0100
CAMEL-26672: Fix ScpOperations.storeFileDirectly to set command and connect
channel (#29317)
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
.../apache/camel/component/scp/ScpOperations.java | 88 +++++++------
.../scp/ScpProducerChecksumFileAlgorithmTest.java | 140 +++++++++++++++++++++
2 files changed, 183 insertions(+), 45 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 662c6fa5a030..ff4d8aedc552 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
@@ -107,11 +107,6 @@ public class ScpOperations implements
RemoteFileOperations<ScpFile> {
checkFileName(name);
ScpConfiguration cfg = endpoint.getConfiguration();
- int timeout = cfg.getConnectTimeout();
- if (LOG.isTraceEnabled()) {
- LOG.trace("Opening channel to {} with {} timeout...",
cfg.remoteServerInformation(),
- timeout > 0 ? (Integer.toString(timeout) + " ms") : "no");
- }
String file = getRemoteFile(name, cfg);
InputStream is = null;
@@ -125,22 +120,55 @@ public class ScpOperations implements
RemoteFileOperations<ScpFile> {
}
}
+ try {
+ if (is == null) {
+ is = exchange.getIn().getMandatoryBody(InputStream.class);
+ }
+ executeScpTransfer(file, is, cfg);
+ } catch (InvalidPayloadException e) {
+ throw new GenericFileOperationFailedException("Cannot store file:
" + name, e);
+ } finally {
+ // must close stream after usage
+ IOHelper.close(is);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean storeFileDirectly(String name, String payload) throws
GenericFileOperationFailedException {
+ checkFileName(name);
+ ObjectHelper.notNull(session, "session");
+ ScpConfiguration cfg = endpoint.getConfiguration();
+
+ String file = getRemoteFile(name, cfg);
+
+ ByteArrayInputStream bis = new
ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
+ try {
+ executeScpTransfer(file, bis, cfg);
+ } finally {
+ IOHelper.close(bis);
+ }
+ return true;
+ }
+
+ /**
+ * Opens an exec channel, sets the scp command, writes the stream, and
disconnects. Both {@link #storeFile} and
+ * {@link #storeFileDirectly} delegate here so the two paths stay in sync.
+ */
+ private void executeScpTransfer(String file, InputStream data,
ScpConfiguration cfg)
+ throws GenericFileOperationFailedException {
+ int timeout = cfg.getConnectTimeout();
+ if (LOG.isTraceEnabled()) {
+ LOG.trace("Opening channel to {} with {} timeout...",
cfg.remoteServerInformation(),
+ timeout > 0 ? (Integer.toString(timeout) + " ms") : "no");
+ }
try {
channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(getScpCommand(cfg, file));
-
try {
- if (is == null) {
- is = exchange.getIn().getMandatoryBody(InputStream.class);
- }
- connectAndWrite(channel, file, is, cfg, timeout);
- } catch (InvalidPayloadException e) {
- throw new GenericFileOperationFailedException("Cannot store
file: " + name, e);
+ connectAndWrite(channel, file, data, cfg, timeout);
} catch (IOException e) {
throw new GenericFileOperationFailedException("Failed to write
file " + file, e);
- } finally {
- // must close stream after usage
- IOHelper.close(is);
}
} catch (JSchException e) {
throw new GenericFileOperationFailedException("Failed to write
file " + file, e);
@@ -149,30 +177,11 @@ public class ScpOperations implements
RemoteFileOperations<ScpFile> {
LOG.trace("Disconnecting 'exec' scp channel");
channel.disconnect();
channel = null;
-
if (LOG.isTraceEnabled()) {
LOG.trace("Channel disconnected from {}",
cfg.remoteServerInformation());
}
}
}
- return true;
- }
-
- @Override
- public boolean storeFileDirectly(String name, String payload) throws
GenericFileOperationFailedException {
- checkFileName(name);
- ByteArrayInputStream bis = null;
- try {
- bis = new ByteArrayInputStream(payload.getBytes());
- ScpConfiguration cfg = endpoint.getConfiguration();
- channel = (ChannelExec) session.openChannel("exec");
- write(channel, name, bis, cfg);
- return true;
- } catch (JSchException | IOException e) {
- throw new GenericFileOperationFailedException("Failed to write
file " + name, e);
- } finally {
- IOHelper.close(bis);
- }
}
@Override
@@ -457,17 +466,6 @@ public class ScpOperations implements
RemoteFileOperations<ScpFile> {
}
}
- private void write(ChannelExec c, String name, InputStream data,
ScpConfiguration cfg) throws IOException {
- OutputStream os = c.getOutputStream();
- InputStream is = c.getInputStream();
-
- try {
- writeFile(name, data, os, is, cfg);
- } finally {
- IOHelper.close(is, os);
- }
- }
-
private void writeFile(String filename, InputStream data, OutputStream os,
InputStream is, ScpConfiguration cfg)
throws IOException {
final int lineFeed = '\n';
diff --git
a/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpProducerChecksumFileAlgorithmTest.java
b/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpProducerChecksumFileAlgorithmTest.java
new file mode 100644
index 000000000000..4e287dbeada2
--- /dev/null
+++
b/components/camel-jsch/src/test/java/org/apache/camel/component/scp/ScpProducerChecksumFileAlgorithmTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.file.FileConstants;
+import org.apache.camel.util.StringHelper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Tests that the SCP producer correctly writes the checksum file when {@code
checksumFileAlgorithm} is set
+ * (CAMEL-26672). {@link ScpOperations#storeFileDirectly} was opening the
channel but never setting the scp command or
+ * connecting it, so every exchange with the option set failed.
+ */
+class ScpProducerChecksumFileAlgorithmTest extends ScpServerTestSupport {
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // no consumer route needed: we verify the files on the server
filesystem directly
+ }
+ };
+ }
+
+ private String getScpUriWithChecksum() {
+ return getScpUri() + "?username=admin&password=admin&knownHostsFile="
+ getKnownHostsFile()
+ + "&checksumFileAlgorithm=md5";
+ }
+
+ private static String md5Hex(byte[] data) throws Exception {
+ return
StringHelper.bytesToHex(MessageDigest.getInstance("MD5").digest(data));
+ }
+
+ /**
+ * Sending a file with {@code checksumFileAlgorithm=md5} must succeed: the
main file is written, the {@code .md5}
+ * checksum file is created with the hex MD5 of the uploaded bytes, and
the {@code CamelFileChecksum} header is set.
+ */
+ @Test
+ void testChecksumFileIsWritten() throws Exception {
+ assumeTrue(isSetupComplete());
+
+ String body = "Hello World";
+ Exchange out = template.request(getScpUriWithChecksum(), exchange -> {
+ exchange.getIn().setHeader(Exchange.FILE_NAME, "hello.txt");
+ exchange.getIn().setBody(body);
+ });
+
+ assertNull(out.getException(), "Exchange must not fail: " +
out.getException());
+
+ // the main file must exist and contain the uploaded body
+ Path mainFile = Path.of(getScpPath(), "hello.txt");
+ assertTrue(mainFile.toFile().exists(), "hello.txt must exist on the
server");
+ assertEquals(body, Files.readString(mainFile));
+
+ // the checksum file must exist and contain the hex MD5 of the
uploaded bytes
+ Path checksumFile = Path.of(getScpPath(), "hello.txt.md5");
+ assertTrue(checksumFile.toFile().exists(), "hello.txt.md5 must exist
on the server");
+ byte[] uploadedBytes = body.getBytes(StandardCharsets.UTF_8);
+ String expectedMd5 = md5Hex(uploadedBytes);
+ assertEquals(expectedMd5, Files.readString(checksumFile));
+
+ // the CamelFileChecksum header must carry the same value
+ String checksumHeader =
out.getMessage().getHeader(FileConstants.FILE_CHECKSUM, String.class);
+ assertNotNull(checksumHeader, "CamelFileChecksum header must be set");
+ assertEquals(expectedMd5, checksumHeader);
+ }
+
+ /**
+ * Same scenario with the file name containing a sub-directory path,
verifying that {@code storeFileDirectly}
+ * correctly resolves the remote name when the name includes the base
directory prefix.
+ */
+ @Test
+ void testChecksumFileIsWrittenInSubDirectory() throws Exception {
+ assumeTrue(isSetupComplete());
+
+ String body = "Bye World";
+ Exchange out = template.request(getScpUriWithChecksum(), exchange -> {
+ exchange.getIn().setHeader(Exchange.FILE_NAME, "subdir/bye.txt");
+ exchange.getIn().setBody(body);
+ });
+
+ assertNull(out.getException(), "Exchange must not fail: " +
out.getException());
+
+ Path mainFile = Path.of(getScpPath(), "subdir", "bye.txt");
+ assertTrue(mainFile.toFile().exists(), "subdir/bye.txt must exist on
the server");
+ assertEquals(body, Files.readString(mainFile));
+
+ Path checksumFile = Path.of(getScpPath(), "subdir", "bye.txt.md5");
+ assertTrue(checksumFile.toFile().exists(), "subdir/bye.txt.md5 must
exist on the server");
+ assertEquals(md5Hex(body.getBytes(StandardCharsets.UTF_8)),
Files.readString(checksumFile));
+ }
+
+ /**
+ * Without {@code checksumFileAlgorithm}, no {@code .md5} file is written
and no checksum header is set (control).
+ */
+ @Test
+ void testNoChecksumFileWithoutOption() throws Exception {
+ assumeTrue(isSetupComplete());
+
+ String uri = getScpUri() +
"?username=admin&password=admin&knownHostsFile=" + getKnownHostsFile();
+ Exchange out = template.request(uri, exchange -> {
+ exchange.getIn().setHeader(Exchange.FILE_NAME, "control.txt");
+ exchange.getIn().setBody("Control");
+ });
+
+ assertNull(out.getException(), "Exchange must not fail");
+ assertTrue(Path.of(getScpPath(), "control.txt").toFile().exists(),
"control.txt must exist");
+ assertFalse(Path.of(getScpPath(),
"control.txt.md5").toFile().exists(), "control.txt.md5 must not exist");
+ assertNull(out.getMessage().getHeader(FileConstants.FILE_CHECKSUM));
+ }
+}