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 de251f07b81b CAMEL-26690: camel-jbang - AI tools: an sftp or ftp
endpoint without a login, and a header read in a split that no step sets
(#29326)
de251f07b81b is described below
commit de251f07b81b33c6627526ee07f04e64515cf320
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 16:32:20 2026 +0100
CAMEL-26690: camel-jbang - AI tools: an sftp or ftp endpoint without a
login, and a header read in a split that no step sets (#29326)
* CAMEL-26690: camel-jbang - AI tools: an sftp or ftp endpoint without a
login next to one of the same server with one
A to: sftp without username and password logs in as anonymous, and the
server refuses it at runtime. In r26 to r28 of connect-sftp-transfer the
from: of the route logged in with {{sftp.username}} and the to: of the
same server did not (5 runs). The write is now refused with the login
options of the other endpoint to copy, in the form the endpoint is
written in (under parameters:, in the uri, or the whole endpoint).
Only reported when another endpoint of the same scheme, host and port
logs in, in the file or another YAML route file of the project; the
placeholders of the project's .properties files are resolved first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
* CAMEL-26690: camel-jbang - AI tools: a header read in a split that no
step sets
A route from file, timer, scheduler, cron or quartz carries no header of
its own, so a ${header.sku} read in a split that no step before sets is
null. r26 and r28 connect-http-client step 1 (3 runs): the toD
http://localhost:8080/stock/${header.sku} in the split over the lines of
an order called /stock/ with no sku, 100 errors of 404 each.
Yesterday's check said this only for a header copied to itself. Now a
header read anywhere in the split is reported once, with what to read:
the field of the line (${body[sku]}), or, when a step before the read
gives the message another body (setBody ${null} for the GET), a variable
set from the line as the first step of the split.
Quiet where a step may set any header (a to:, a process, a script), for
consumers whose messages or callers can carry headers (direct:, rest,
http...), for Camel's own headers and the job headers of quartz and
cron, and for a name the route keeps in a variable, which the mix-up
check reports.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../core/commands/ai/HeaderPropertyMixups.java | 288 +++++++++++++--
.../jbang/core/commands/ai/RemoteFileLogins.java | 401 +++++++++++++++++++++
.../jbang/core/commands/ai/SourceValidator.java | 2 +
.../core/commands/ai/HeaderPropertyMixupsTest.java | 167 ++++++++-
.../core/commands/ai/RemoteFileLoginsTest.java | 194 ++++++++++
5 files changed, 1006 insertions(+), 46 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
index 76be04480f4a..3003e1ab0741 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
@@ -47,11 +47,16 @@ import org.yaml.snakeyaml.nodes.SequenceNode;
* route sets in both forms.
* <p/>
* In the same routes, a header copied to itself that nothing sets before
({@code setHeader: sku} with
- * {@code ${header.sku}}) is null too, see {@link #selfCopies(String,
ProjectFacts)}.
+ * {@code ${header.sku}}) is null too, and so is a header read in a split that
nothing sets before, see
+ * {@link #unsetHeaders(String, ProjectFacts)}.
*/
final class HeaderPropertyMixups {
private static final Set<String> PLAIN_CONSUMERS = Set.of("timer",
"scheduler", "cron", "quartz", "file");
+ /** The headers of the job that a quartz consumer, and a cron one (which
runs on quartz), sets on each message. */
+ private static final Set<String> QUARTZ_HEADERS = Set.of("calendar",
"fireTime", "jobDetail", "jobInstance",
+ "jobRunTime", "mergedJobDataMap", "nextFireTime",
"previousFireTime", "refireCount", "result",
+ "scheduledFireTime", "scheduler", "trigger", "triggerName",
"triggerGroup");
private static final Pattern HEADER_READ
=
Pattern.compile("\\$\\{(?:in\\.)?headers?(?:\\.([A-Za-z_][\\w-]*)|\\[['\"]?([A-Za-z_][\\w-]*)['\"]?])");
private static final Pattern PROPERTY_READ
@@ -224,12 +229,12 @@ final class HeaderPropertyMixups {
}
/**
- * The problems, each "Line N: message", with the fields of the JSON data
of the project for a header copied to
- * itself.
+ * The problems, each "Line N: message", with the fields of the JSON data
of the project for a header that nothing
+ * sets.
*/
static List<String> validate(String content, ProjectFacts facts) {
List<String> answer = mixups(content);
- answer.addAll(selfCopies(content, facts));
+ answer.addAll(unsetHeaders(content, facts));
return answer;
}
@@ -383,6 +388,16 @@ final class HeaderPropertyMixups {
"convertVariableTo", "delay", "throttle", "stop", "filter",
"choice", "split", "loop", "step", "pipeline",
"doTry", "idempotentConsumer", "validate", "setExchangePattern");
+ /** The EIPs that evaluate their endpoint, a uri with ${...}, before they
send to it: the headers it reads count. */
+ private static final Set<String> SENDS_TO_EXPRESSION = Set.of("toD",
"to-d", "enrich", "pollEnrich", "poll-enrich",
+ "wireTap", "wire-tap", "recipientList", "recipient-list",
"routingSlip", "routing-slip");
+
+ /** The steps that give the message another body. */
+ private static final Set<String> BODY_STEPS = Set.of("setBody",
"transform", "marshal", "unmarshal", "convertBodyTo");
+
+ /** The keys of the steps an EIP holds, which the walk goes through in
their own order. */
+ private static final Set<String> HELD_STEPS = Set.of("steps", "when",
"otherwise", "doCatch", "doFinally");
+
/** The languages that run code, which can set a header of any name. */
private static final Set<String> SCRIPTS = Set.of("groovy", "js",
"javascript", "python", "java", "joor", "jsh",
"mvel", "ognl", "spel", "jactl", "quickjs", "method", "bean",
"csimple", "datasonnet", "wasm");
@@ -415,6 +430,20 @@ final class HeaderPropertyMixups {
}
}
+ /**
+ * The split a step is in: the field of the data it gives the elements of
(null when it is not one), and the first
+ * step in it that gives the message another body, with its line.
+ */
+ private static final class Split {
+ final String field;
+ String bodyStep;
+ int bodyLine;
+
+ Split(String field) {
+ this.field = field;
+ }
+ }
+
/**
* A setHeader whose value is one header that nothing set before it: the
line, the name set, the name read, the
* field the split it is in gives the elements of, and where the route
keeps the name read instead (a variable or an
@@ -424,18 +453,42 @@ final class HeaderPropertyMixups {
}
/**
- * A setHeader that copies a header to itself, setHeader sku with
${header.sku}, when nothing sets that header
- * before it: the route consumes from an endpoint that carries no header
of its own (timer, scheduler, cron, quartz,
- * file), and no step before it sets the header. The header is null, and
stays null, with no error. r23 to r27
- * connect-http-client step 2 (10 runs): in a split over the lines of an
order, the HTTP call had no sku, 98 errors
- * of 404 before the model found ${body[sku]}. Only a header that a later
step of the route reads, as the toD did. A
- * step that may set a header the check does not see (a to:, a process, a
bean, a script) makes it say nothing after
- * it. A path parameter of a rest in the file is a header of the route the
rest calls, not of these routes.
+ * A header read in a split that nothing set before it: the line, the
name, the field the split gives the elements
+ * of, the step before it in the split that gave the message another body
(null when none) and its line, and where
+ * the route keeps the name instead (a variable or an exchange property),
or null.
+ */
+ private record UnsetRead(int line, String name, String split, String
bodyStep, int bodyLine, Kind kept) {
+ }
+
+ /** What the walk of a route finds, and the lines of the content for the
line of a read. */
+ private static final class Found {
+ final List<UnsetCopy> copies = new ArrayList<>();
+ final List<UnsetRead> reads = new ArrayList<>();
+ final String[] lines;
+
+ Found(String[] lines) {
+ this.lines = lines;
+ }
+ }
+
+ /**
+ * A header that nothing sets before it is read: the route consumes from
an endpoint that carries no header of its
+ * own (timer, scheduler, cron, quartz, file), and no step before it sets
the header. The header is null, and stays
+ * null, with no error. A step that may set a header the check does not
see (a to:, a process, a bean, a script)
+ * makes it say nothing after it. A path parameter of a rest in the file
is a header of the route the rest calls,
+ * not of these routes.
+ * <p/>
+ * A setHeader that copies a header to itself, setHeader sku with
${header.sku}: r23 to r27 connect-http-client step
+ * 2 (10 runs), in a split over the lines of an order, the HTTP call had
no sku, 98 errors of 404 before the model
+ * found ${body[sku]}. Only a header that a later step of the route reads,
as the toD did; a copy from another
+ * header that nothing sets (needed with ${header.qty}) is named in the
same message.
* <p/>
- * Only a copy to itself is reported; a copy from another header that
nothing sets (needed with ${header.qty}) is
- * named in the same message.
+ * A header read in a split: r26 and r28 connect-http-client step 1 (3
runs), toD
+ * http://localhost:8080/stock/${header.sku} in the split over the lines
of an order, 100 errors of 404 each. In the
+ * split the body is the element, so the value is a field of it; when a
step before the read gave the message
+ * another body (setBody ${null} for the GET), the field is kept in a
variable first.
*/
- static List<String> selfCopies(String content, ProjectFacts facts) {
+ static List<String> unsetHeaders(String content, ProjectFacts facts) {
List<String> answer = new ArrayList<>();
if (content == null || !content.contains("header")) {
return answer;
@@ -450,6 +503,11 @@ final class HeaderPropertyMixups {
return answer;
}
String[] lines = content.split("\n", -1);
+ Set<String> pathParameters = new LinkedHashSet<>();
+ Matcher pm = PATH_PARAMETER.matcher(content);
+ while (pm.find()) {
+ pathParameters.add(pm.group(1));
+ }
try {
for (Node root : roots) {
if (!(root instanceof SequenceNode entries)) {
@@ -462,16 +520,36 @@ final class HeaderPropertyMixups {
if (uri == null || !PLAIN_CONSUMERS.contains(scheme(uri)))
{
continue;
}
- List<UnsetCopy> copies = new ArrayList<>();
- walk(value(from, "steps"), new Seen(), null, copies);
+ Seen start = new Seen();
+ if ("quartz".equals(scheme(uri)) ||
"cron".equals(scheme(uri))) {
+ start.headers.addAll(QUARTZ_HEADERS);
+ }
+ Found found = new Found(lines);
+ walk(value(from, "steps"), start, null, found);
// only a header that a later step reads: a copy nothing
reads is harmless (a route that worked had
// one, with the value read from a variable)
+ List<UnsetCopy> copies = found.copies;
int end = from.getEndMark().getLine() + 1;
copies.removeIf(c -> !readAfter(lines, c.line(), end,
c.name()));
ProjectFacts.JsonFields data = consumedData(uri, from,
facts);
+ // the headers a message names already
+ Set<String> named = new LinkedHashSet<>();
for (UnsetCopy copy : copies) {
if (copy.name().equals(copy.read())) {
answer.add(selfCopyMessage(copy, copies, data));
+ copies.forEach(c -> named.add(c.read()));
+ }
+ }
+ // a name the route keeps in a variable or exchange
property, and never in a header, is said by
+ // mixups(): the read of a header there is the mix-up
+ Seen all = new Seen();
+ setAnywhere(from, all);
+ for (UnsetRead read : found.reads) {
+ String name = read.name();
+ boolean mixup = !all.headers.contains(name) &&
!pathParameters.contains(name)
+ && (all.variables.contains(name) ||
all.properties.contains(name));
+ if (!mixup && named.add(name)) {
+ answer.add(unsetReadMessage(read, data));
}
}
}
@@ -495,8 +573,11 @@ final class HeaderPropertyMixups {
return false;
}
- /** Walks the steps in order, with what was set before each one,
collecting the copies of unset headers. */
- private static void walk(Node steps, Seen seen, String split,
List<UnsetCopy> copies) {
+ /**
+ * Walks the steps in order, with what was set before each one, collecting
the copies of unset headers, and the
+ * headers read in a split that nothing set.
+ */
+ private static void walk(Node steps, Seen seen, Split split, Found found) {
if (!(steps instanceof SequenceNode list)) {
return;
}
@@ -507,12 +588,21 @@ final class HeaderPropertyMixups {
}
String eip = scalar(step.getValue().get(0).getKeyNode());
Node body = step.getValue().get(0).getValueNode();
- if (!QUIET_STEPS.contains(eip) || runsCode(body)) {
+ boolean code = runsCode(body);
+ if (split != null && !code && (QUIET_STEPS.contains(eip) ||
SENDS_TO_EXPRESSION.contains(eip))) {
+ // what the step reads comes before what it sets
+ reads(eip, body, seen, split, found);
+ }
+ if (!QUIET_STEPS.contains(eip) || code) {
seen.anyHeader = true;
continue;
}
+ if (split != null && split.bodyStep == null &&
BODY_STEPS.contains(eip)) {
+ split.bodyStep = eip;
+ split.bodyLine = item.getStartMark().getLine() + 1;
+ }
switch (eip) {
- case "setHeader" -> setHeader(body, seen, split, copies);
+ case "setHeader" -> setHeader(body, seen, split, found);
case "setHeaders" -> namesOf(value(body, "headers"),
seen.headers);
case "setVariable" -> name(body, seen.variables);
case "setVariables" -> namesOf(value(body, "variables"),
seen.variables);
@@ -520,26 +610,26 @@ final class HeaderPropertyMixups {
case "split" -> {
// each element starts from what was set before the split
Seen inner = seen.copy();
- walk(value(body, "steps"), inner, split == null ?
splitField(body) : null, copies);
+ walk(value(body, "steps"), inner, new Split(split == null
? splitField(body) : null), found);
seen.merge(inner);
}
case "loop" -> {
// a pass of the loop sees what the passes before it set
Seen first = seen.copy();
- walk(value(body, "steps"), first, split, new
ArrayList<>());
+ walk(value(body, "steps"), first, split, new
Found(found.lines));
Seen inner = seen.copy();
inner.merge(first);
- walk(value(body, "steps"), inner, split, copies);
+ walk(value(body, "steps"), inner, split, found);
seen.merge(inner);
}
- default -> nested(body, seen, split, copies);
+ default -> nested(body, seen, split, found);
}
variableReceive(body, seen);
}
}
/** The steps of the EIPs that hold some: when and otherwise of a choice,
doCatch and doFinally of a doTry. */
- private static void nested(Node body, Seen seen, String split,
List<UnsetCopy> copies) {
+ private static void nested(Node body, Seen seen, Split split, Found found)
{
if (!(body instanceof MappingNode map)) {
return;
}
@@ -548,49 +638,147 @@ final class HeaderPropertyMixups {
String key = scalar(t.getKeyNode());
if ("steps".equals(key)) {
Seen inner = seen.copy();
- walk(t.getValueNode(), inner, split, copies);
+ walk(t.getValueNode(), inner, split, found);
branches.add(inner);
} else if ("when".equals(key) || "doCatch".equals(key)) {
if (t.getValueNode() instanceof SequenceNode items) {
for (Node branch : items.getValue()) {
Seen inner = seen.copy();
- walk(value(branch, "steps"), inner, split, copies);
+ walk(value(branch, "steps"), inner, split, found);
branches.add(inner);
}
}
} else if ("otherwise".equals(key) || "doFinally".equals(key)) {
Seen inner = seen.copy();
- walk(value(t.getValueNode(), "steps"), inner, split, copies);
+ walk(value(t.getValueNode(), "steps"), inner, split, found);
branches.add(inner);
}
}
branches.forEach(seen::merge);
}
- private static void setHeader(Node body, Seen seen, String split,
List<UnsetCopy> copies) {
+ private static void setHeader(Node body, Seen seen, Split split, Found
found) {
String name = scalar(value(body, "name"));
if (name == null) {
return;
}
- Node simple = value(body, "simple");
- if (simple == null && value(body, "expression") instanceof MappingNode
expression) {
- simple = value(expression, "simple");
- }
- if (simple instanceof MappingNode m) {
- simple = value(m, "expression");
- }
+ Node simple = valueOf(body);
Matcher m = simple instanceof ScalarNode s ?
ONE_HEADER.matcher(s.getValue().trim()) : null;
if (m != null && m.matches() && !seen.anyHeader) {
String read = m.group(1) != null ? m.group(1) : m.group(2);
if (!read.startsWith("Camel") && !seen.headers.contains(read)) {
Kind kept = seen.variables.contains(read)
? Kind.VARIABLE : seen.properties.contains(read) ?
Kind.PROPERTY : null;
- copies.add(new UnsetCopy(simple.getStartMark().getLine() + 1,
name, read, split, kept));
+ found.copies.add(new UnsetCopy(
+ simple.getStartMark().getLine() + 1, name, read, split
!= null ? split.field : null, kept));
}
}
seen.headers.add(name);
}
+ /** The simple expression of a setHeader: simple: x, simple: {expression:
x}, or the same under expression:. */
+ private static Node valueOf(Node body) {
+ Node simple = value(body, "simple");
+ if (simple == null && value(body, "expression") instanceof MappingNode
expression) {
+ simple = value(expression, "simple");
+ }
+ if (simple instanceof MappingNode m) {
+ simple = value(m, "expression");
+ }
+ return simple;
+ }
+
+ /**
+ * The headers a step in a split reads that nothing set before it: in its
expressions, uri and options, and the
+ * conditions of a choice, not in the steps it holds. A copy to itself is
the copy check's.
+ */
+ private static void reads(String eip, Node body, Seen seen, Split split,
Found found) {
+ if (seen.anyHeader) {
+ return;
+ }
+ if ("setHeader".equals(eip) && valueOf(body) instanceof ScalarNode s) {
+ Matcher self = ONE_HEADER.matcher(s.getValue().trim());
+ if (self.matches() && (self.group(1) != null ? self.group(1) :
self.group(2))
+ .equals(scalar(value(body, "name")))) {
+ return;
+ }
+ }
+ List<ScalarNode> texts = new ArrayList<>();
+ texts(body, texts);
+ for (ScalarNode text : texts) {
+ Matcher m = HEADER_READ.matcher(text.getValue());
+ while (m.find()) {
+ String name = m.group(1) != null ? m.group(1) : m.group(2);
+ if (name.startsWith("Camel") || seen.headers.contains(name)) {
+ continue;
+ }
+ Kind kept = seen.variables.contains(name)
+ ? Kind.VARIABLE : seen.properties.contains(name) ?
Kind.PROPERTY : null;
+ found.reads.add(new UnsetRead(
+ lineOf(text, m.group(), found.lines), name,
split.field, split.bodyStep, split.bodyLine, kept));
+ }
+ }
+ }
+
+ /** The texts with ${...} of a step, outside the steps it holds; the
conditions of the when items of a choice. */
+ private static void texts(Node node, List<ScalarNode> into) {
+ if (node instanceof ScalarNode s) {
+ if (s.getValue().contains("${")) {
+ into.add(s);
+ }
+ } else if (node instanceof SequenceNode seq) {
+ seq.getValue().forEach(n -> texts(n, into));
+ } else if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ String key = scalar(t.getKeyNode());
+ if (key == null || !HELD_STEPS.contains(key)) {
+ texts(t.getValueNode(), into);
+ } else if ("when".equals(key) && t.getValueNode() instanceof
SequenceNode items) {
+ for (Node when : items.getValue()) {
+ if (when instanceof MappingNode w) {
+ w.getValue().stream().filter(c ->
!"steps".equals(scalar(c.getKeyNode())))
+ .forEach(c -> texts(c.getValueNode(),
into));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * The 1-based line of the text that has the token, from the line the text
starts at (a block scalar starts after).
+ */
+ private static int lineOf(ScalarNode text, String token, String[] lines) {
+ int first = text.getStartMark().getLine();
+ for (int i = first; i < Math.min(lines.length,
text.getEndMark().getLine() + 1); i++) {
+ if (lines[i].contains(token)) {
+ return i + 1;
+ }
+ }
+ return first + 1;
+ }
+
+ /** The names that the steps under the node set, wherever they are and in
any order. */
+ private static void setAnywhere(Node node, Seen into) {
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ Node v = t.getValueNode();
+ switch (String.valueOf(scalar(t.getKeyNode()))) {
+ case "setHeader" -> name(v, into.headers);
+ case "setHeaders" -> namesOf(value(v, "headers"),
into.headers);
+ case "setVariable" -> name(v, into.variables);
+ case "setVariables" -> namesOf(value(v, "variables"),
into.variables);
+ case "setProperty" -> name(v, into.properties);
+ default -> {
+ }
+ }
+ setAnywhere(v, into);
+ }
+ } else if (node instanceof SequenceNode seq) {
+ seq.getValue().forEach(n -> setAnywhere(n, into));
+ }
+ }
+
/** Whether a step runs code of its own, outside the steps it holds: a
script language can set any header. */
private static boolean runsCode(Node node) {
if (node instanceof MappingNode map) {
@@ -670,6 +858,32 @@ final class HeaderPropertyMixups {
return sb.toString();
}
+ /**
+ * The message of a header read in a split that nothing set: where the
value is; when a step before the read gave
+ * the message another body, the field is kept in a variable first, at the
start of the split.
+ */
+ private static String unsetReadMessage(UnsetRead read,
ProjectFacts.JsonFields data) {
+ String name = read.name();
+ String head = "Line " + read.line() + ": ${header." + name + "} is
null here: no step before this line sets the"
+ + " header " + name + ". ";
+ if (read.kept() != null) {
+ return head + "The route keeps " + name + " in " +
read.kept().what + ": write ${" + read.kept().read + name
+ + "}";
+ }
+ Set<String> element = read.split() != null && data != null ?
data.elementFields().get(read.split()) : null;
+ boolean ofTheData = element != null && !element.contains(name) &&
data.fields().contains(name);
+ if (read.bodyStep() == null || ofTheData) {
+ return head + whereTheValueIs(name, read.split(), data);
+ }
+ String body = element != null && element.contains(name)
+ ? "In the split the body is one of the " + read.split() + " of
" + data.where() + " ("
+ + String.join(", ", new TreeSet<>(element)) + ")"
+ : "In the split the body is the element";
+ return head + body + ", until " + read.bodyStep() + " on line " +
read.bodyLine() + " replaces it: keep " + name
+ + " in a variable first, as the first step of the split:
setVariable: {name: " + name + ", simple: \"${body["
+ + name + "]}\"}, and write ${variable." + name + "}";
+ }
+
/** What to write for a value the route has not set as a header: a field
of the split element, or of the data. */
private static String whereTheValueIs(String name, String split,
ProjectFacts.JsonFields data) {
Set<String> element = split != null && data != null ?
data.elementFields().get(split) : null;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RemoteFileLogins.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RemoteFileLogins.java
new file mode 100644
index 000000000000..6f433773e48d
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RemoteFileLogins.java
@@ -0,0 +1,401 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+import org.yaml.snakeyaml.nodes.MappingNode;
+import org.yaml.snakeyaml.nodes.Node;
+import org.yaml.snakeyaml.nodes.NodeTuple;
+import org.yaml.snakeyaml.nodes.ScalarNode;
+import org.yaml.snakeyaml.nodes.SequenceNode;
+
+/**
+ * A remote file endpoint (sftp, ftp, ftps) without a username logs in as
anonymous. r26 to r28 connect-sftp-transfer (5
+ * runs): the from: of the route downloaded the batches with {{sftp.username}}
and {{sftp.password}}, and the to: that
+ * uploaded a receipt, or a failed batch, to the same server had no login:
"Cannot connect to
+ * sftp://anonymous@localhost:2222", Auth fail, and the write had been
accepted.
+ * <p/>
+ * Only reported when another endpoint of the same server (the same scheme,
host and port, with the placeholders of the
+ * project's .properties files resolved) logs in, in the file or in another
YAML route file of the project: the message
+ * gives the login options of that endpoint to copy, in the form the endpoint
is written in.
+ */
+final class RemoteFileLogins {
+
+ private static final Set<String> SCHEMES = Set.of("sftp", "ftp", "ftps");
+ /** The keys whose value is an endpoint: a uri, or a mapping with uri: and
parameters:. */
+ private static final Set<String> ENDPOINT_KEYS = Set.of("from", "to",
"toD", "to-d", "wireTap", "wire-tap",
+ "enrich", "pollEnrich", "poll-enrich", "poll");
+ /** The options that log in: an endpoint with one of them is not
anonymous. */
+ private static final Set<String> LOGIN_OPTIONS = Set.of("username",
"password", "privateKey", "privateKeyFile",
+ "privateKeyUri", "keyPair", "account");
+ /** The login options of the components when the catalog cannot say (its
security group). */
+ private static final Set<String> SECURITY_OPTIONS = Set.of("username",
"password", "account", "privateKey",
+ "privateKeyFile", "privateKeyUri", "privateKeyPassphrase",
"keyPair", "knownHosts", "knownHostsFile",
+ "knownHostsUri", "useUserKnownHostsFile", "strictHostKeyChecking");
+ private static final Pattern PLACEHOLDER =
Pattern.compile("\\{\\{([^}:]+)(?::([^}]*))?}}");
+
+ private RemoteFileLogins() {
+ }
+
+ /**
+ * A remote file endpoint as written: its line, the EIP, the server, its
options (path and query options and the
+ * parameters, by name, as written) and how its options are written (under
parameters:, in the uri, or neither).
+ */
+ private record Endpoint(
+ int line, String eip, String uri, String scheme, String host,
String port, boolean userInfo,
+ Map<String, String> options, boolean parameters, boolean query) {
+
+ boolean logsIn() {
+ return userInfo ||
options.keySet().stream().anyMatch(LOGIN_OPTIONS::contains);
+ }
+
+ boolean hasUsername() {
+ return userInfo || options.containsKey("username");
+ }
+ }
+
+ /**
+ * The endpoints without a login whose server another endpoint of the file
or project logs in to, each "Line N:
+ * message".
+ *
+ * @param content the YAML route
+ * @param catalog for the login options of the component (its security
group)
+ * @param directory the project directory, for its .properties files and
other route files; may be null
+ * @param fileName the file of the content, left out of the other route
files
+ */
+ static List<String> validate(String content, CamelCatalog catalog, Path
directory, String fileName) {
+ List<String> answer = new ArrayList<>();
+ if (content == null || !content.contains("ftp")) {
+ return answer;
+ }
+ List<Endpoint> endpoints = endpoints(content);
+ if (endpoints.stream().allMatch(Endpoint::logsIn)) {
+ return answer;
+ }
+ Map<String, String> properties = directory != null ?
properties(directory) : Map.of();
+ // the endpoints of the other route files of the project, by file, as
places to copy a login from
+ Map<String, List<Endpoint>> others = new LinkedHashMap<>();
+ others.put(null, endpoints);
+ if (directory != null) {
+ others.putAll(otherFiles(directory, fileName));
+ }
+ try {
+ for (Endpoint e : endpoints) {
+ String server = e.logsIn() ? null : server(e, properties);
+ if (server == null) {
+ continue;
+ }
+ for (Map.Entry<String, List<Endpoint>> file :
others.entrySet()) {
+ Endpoint other = file.getValue().stream()
+ .filter(o -> o != e && o.hasUsername() &&
server.equals(server(o, properties)))
+ .findFirst().orElse(null);
+ if (other != null) {
+ answer.add(message(e, other, file.getKey(), catalog));
+ break;
+ }
+ }
+ }
+ } catch (RuntimeException ex) {
+ // a shape the check does not know: a check must never fail the
validation
+ }
+ return answer;
+ }
+
+ private static String message(Endpoint e, Endpoint other, String
otherFile, CamelCatalog catalog) {
+ // the login options of the other endpoint that this one does not
have, as written there
+ Map<String, String> login = new LinkedHashMap<>();
+ Set<String> security = securityOptions(catalog, e.scheme());
+ if (other.userInfo() && !other.options().containsKey("username")) {
+ login.put("username", userOf(other.uri()));
+ }
+ other.options().forEach((k, v) -> {
+ if (security.contains(k) && !e.options().containsKey(k)) {
+ login.put(k, v);
+ }
+ });
+ List<String> pairs = new ArrayList<>();
+ login.forEach((k, v) -> pairs.add(k + ": " + yamlValue(v)));
+ String fix;
+ if (e.parameters()) {
+ fix = "add the same login under the parameters: of this " +
e.eip() + ": " + String.join(", ", pairs);
+ } else if (e.query()) {
+ StringBuilder query = new StringBuilder();
+ login.forEach((k, v) ->
query.append('&').append(k).append('=').append(v));
+ fix = "add the same login to its uri: " + query;
+ } else {
+ fix = "write it with the same login: " + e.eip() + ": {uri: " +
YamlLines.quoted(e.uri())
+ + ", parameters: {" + String.join(", ", pairs) + "}}";
+ }
+ String where = otherFile != null
+ ? "the " + other.eip() + ": of " + otherFile + " (line " +
other.line() + ")"
+ : "the " + other.eip() + ": on line " + other.line();
+ return "Line " + e.line() + ": " + e.eip() + ": " + e.uri() + " has no
username, so it logs in as anonymous,"
+ + " while " + where + " logs in to the same server: " + fix;
+ }
+
+ /** The options of the security group of the component, such as username
and useUserKnownHostsFile. */
+ private static Set<String> securityOptions(CamelCatalog catalog, String
scheme) {
+ try {
+ ComponentModel model = catalog != null ?
catalog.componentModel(scheme) : null;
+ if (model != null) {
+ Set<String> answer = new HashSet<>();
+ for (ComponentModel.EndpointOptionModel o :
model.getEndpointOptions()) {
+ if ("security".equals(o.getGroup()) || o.getLabel() !=
null && o.getLabel().contains("security")) {
+ answer.add(o.getName());
+ }
+ }
+ if (!answer.isEmpty()) {
+ return answer;
+ }
+ }
+ } catch (Exception e) {
+ // the catalog cannot say: the usual login options
+ }
+ return SECURITY_OPTIONS;
+ }
+
+ /** A value as YAML, quoted when it has a placeholder or a character YAML
reads itself. */
+ private static String yamlValue(String value) {
+ return value.isEmpty() ||
value.matches(".*[{}:#\\[\\],&*!|>'\"%@`].*") || !value.equals(value.strip())
+ ? YamlLines.quoted(value) : value;
+ }
+
+ /** The server of the endpoint: its scheme, host and port, placeholders
resolved, or null when it has no host. */
+ private static String server(Endpoint e, Map<String, String> properties) {
+ String host = resolve(e.host(), properties);
+ if (host == null || host.isBlank()) {
+ return null;
+ }
+ String port = resolve(e.port(), properties);
+ if (port == null || port.isBlank()) {
+ port = "sftp".equals(e.scheme()) ? "22" : "21";
+ }
+ return e.scheme() + "://" + host.toLowerCase(Locale.ROOT) + ":" + port;
+ }
+
+ /** The text with the {{key}} placeholders the properties have replaced by
their values, {{key:default}} too. */
+ private static String resolve(String text, Map<String, String> properties)
{
+ if (text == null || !text.contains("{{")) {
+ return text;
+ }
+ Matcher m = PLACEHOLDER.matcher(text);
+ StringBuilder sb = new StringBuilder();
+ while (m.find()) {
+ String value = properties.get(m.group(1).strip());
+ if (value == null) {
+ value = m.group(2);
+ }
+ m.appendReplacement(sb, Matcher.quoteReplacement(value != null ?
value.strip() : m.group()));
+ }
+ m.appendTail(sb);
+ return sb.toString();
+ }
+
+ /** The remote file endpoints of a YAML route, in the order they are
written. */
+ private static List<Endpoint> endpoints(String content) {
+ List<Endpoint> answer = new ArrayList<>();
+ try {
+ for (Node root : new Yaml(new SafeConstructor(new
LoaderOptions())).composeAll(new StringReader(content))) {
+ collect(root, answer);
+ }
+ } catch (RuntimeException e) {
+ // not YAML: the schema check says so
+ }
+ return answer;
+ }
+
+ private static void collect(Node node, List<Endpoint> answer) {
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ String key = scalar(t.getKeyNode());
+ if (key != null && ENDPOINT_KEYS.contains(key)) {
+ Endpoint e = endpoint(key, t.getValueNode());
+ if (e != null) {
+ answer.add(e);
+ }
+ }
+ collect(t.getValueNode(), answer);
+ }
+ } else if (node instanceof SequenceNode seq) {
+ seq.getValue().forEach(n -> collect(n, answer));
+ }
+ }
+
+ /** The endpoint of an EIP: to: "sftp://...", or to: {uri: sftp,
parameters: {...}}; null for another scheme. */
+ private static Endpoint endpoint(String eip, Node value) {
+ Node uriNode = value instanceof MappingNode ? value(value, "uri") :
value;
+ String uri = scalar(uriNode);
+ if (uri == null) {
+ return null;
+ }
+ uri = uri.strip();
+ int colon = uri.indexOf(':');
+ String scheme = (colon > 0 ? uri.substring(0, colon) :
uri).toLowerCase(Locale.ROOT);
+ if (!SCHEMES.contains(scheme)) {
+ return null;
+ }
+ Map<String, String> options = new LinkedHashMap<>();
+ String rest = colon > 0 ? uri.substring(colon + 1) : "";
+ boolean query = rest.contains("?");
+ if (query) {
+ for (String pair : rest.substring(rest.indexOf('?') +
1).split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq > 0) {
+ options.put(pair.substring(0, eq), pair.substring(eq + 1));
+ }
+ }
+ rest = rest.substring(0, rest.indexOf('?'));
+ }
+ if (rest.startsWith("//")) {
+ rest = rest.substring(2);
+ }
+ String authority = rest.contains("/") ? rest.substring(0,
rest.indexOf('/')) : rest;
+ boolean userInfo = authority.contains("@");
+ if (userInfo) {
+ authority = authority.substring(authority.lastIndexOf('@') + 1);
+ }
+ // host:port, where the host or port may be a placeholder: the last
colon outside {{...}}
+ String host = authority;
+ String port = null;
+ int split = lastColonOutsidePlaceholders(authority);
+ if (split >= 0) {
+ host = authority.substring(0, split);
+ port = authority.substring(split + 1);
+ }
+ boolean parameters = false;
+ if (value(value, "parameters") instanceof MappingNode params) {
+ parameters = true;
+ for (NodeTuple t : params.getValue()) {
+ String k = scalar(t.getKeyNode());
+ String v = scalar(t.getValueNode());
+ if (k != null) {
+ options.put(k, v != null ? v : "");
+ }
+ }
+ }
+ if (options.containsKey("host")) {
+ host = options.get("host");
+ }
+ if (options.containsKey("port")) {
+ port = options.get("port");
+ }
+ return new Endpoint(
+ uriNode.getStartMark().getLine() + 1, eip, uri, scheme, host,
port, userInfo, options,
+ parameters, query);
+ }
+
+ private static int lastColonOutsidePlaceholders(String text) {
+ int depth = 0;
+ int answer = -1;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth = Math.max(0, depth - 1);
+ } else if (c == ':' && depth == 0) {
+ answer = i;
+ }
+ }
+ return answer;
+ }
+
+ /** The user of sftp://user:secret@host/dir. */
+ private static String userOf(String uri) {
+ String rest = uri.substring(uri.indexOf(':') + 1);
+ rest = rest.startsWith("//") ? rest.substring(2) : rest;
+ String user = rest.substring(0, rest.lastIndexOf('@'));
+ return user.contains(":") ? user.substring(0, user.indexOf(':')) :
user;
+ }
+
+ /** The remote file endpoints of the other YAML route files of the
project, by file. */
+ private static Map<String, List<Endpoint>> otherFiles(Path directory,
String fileName) {
+ Map<String, List<Endpoint>> answer = new LinkedHashMap<>();
+ Path self = fileName != null ? directory.resolve(fileName).normalize()
: null;
+ try {
+ for (Path p : AuthoringTools.projectFiles(directory)) {
+ String rel = AuthoringTools.relativePath(directory, p);
+ if (p.normalize().equals(self) ||
!"yaml".equals(AuthoringTools.fileType(rel))
+ || !"route".equals(AuthoringTools.fileKind(p, rel,
"yaml"))) {
+ continue;
+ }
+ String text = Files.readString(p);
+ if (text.contains("ftp")) {
+ answer.put(rel, endpoints(text));
+ }
+ }
+ } catch (IOException | RuntimeException e) {
+ // an unreadable project has no other endpoint to compare
+ }
+ return answer;
+ }
+
+ /** The properties of the .properties files of the project directory, as
camel run reads them. */
+ private static Map<String, String> properties(Path directory) {
+ Map<String, String> answer = new LinkedHashMap<>();
+ try (Stream<Path> files = Files.list(directory)) {
+ files.filter(p ->
p.getFileName().toString().endsWith(".properties")).sorted().forEach(p -> {
+ Properties props = new Properties();
+ try (Reader r = Files.newBufferedReader(p)) {
+ props.load(r);
+ } catch (IOException e) {
+ // an unreadable file sets nothing
+ }
+ props.stringPropertyNames().forEach(k -> answer.putIfAbsent(k,
props.getProperty(k)));
+ });
+ } catch (IOException e) {
+ // no directory listing: no properties
+ }
+ return answer;
+ }
+
+ private static Node value(Node node, String key) {
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ if (key.equals(scalar(t.getKeyNode()))) {
+ return t.getValueNode();
+ }
+ }
+ }
+ return null;
+ }
+
+ private static String scalar(Node node) {
+ return node instanceof ScalarNode s ? s.getValue() : null;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index abe5b489c80e..f988ea115e90 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -186,6 +186,8 @@ public final class SourceValidator {
// a header read where the route keeps the name in an exchange
property, or the reverse (CAMEL-25516),
// and a header copied to itself that nothing sets
msgs = new ArrayList<>(HeaderPropertyMixups.validate(content,
facts));
+ // an sftp or ftp endpoint without a login, next to one of the
same server with one
+ msgs.addAll(RemoteFileLogins.validate(content, catalog,
directory, fileName));
}
// an option at the column of its EIP: the lines to write, which a
model copies (the words it does not,
// CAMEL-25556)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
index fc358cca485d..ef6fcf5c19b3 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
@@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* CAMEL-25516: a header, exchange property or variable read where the route
keeps the name in another of them; and a
- * header copied to itself that nothing sets.
+ * header copied to itself, or read in a split, that nothing sets.
*/
class HeaderPropertyMixupsTest {
@@ -292,20 +292,26 @@ class HeaderPropertyMixupsTest {
"name: orderId\n expression:\n
simple:\n"
+ " expression: \"${header.orderId}\"")
.replace("${variable.orderId}: ", "${header.orderId}: ");
- assertThat(SourceValidator.validate("http-client.camel.yaml", orderId,
new DefaultCamelCatalog(), null, dir))
- .singleElement().asString()
+ msgs = SourceValidator.validate("http-client.camel.yaml", orderId, new
DefaultCamelCatalog(), null, dir);
+ assertThat(msgs).hasSize(2);
+ assertThat(msgs.get(0))
.startsWith("Line 21: ${header.orderId} is null here: no step
before this line sets the header orderId")
.contains("orderId is a field of the data that was split, not
of one of the lines: read it before the"
+ " split, into a variable (setVariable), and write
${variable.orderId} in the split");
+ // the sku that the copy no longer sets, read in the uri after the
body was emptied
+ assertThat(msgs.get(1)).startsWith("Line 32: ${header.sku} is null
here: no step before this line sets the header"
+ + " sku. In the split the body is
one of the lines of the files in orders/"
+ + " (price, qty, sku), until
setBody on line 27 replaces it");
}
@Test
void aHeaderCopiedToItselfIsNotReportedWhereItCanBeSet() {
String split = " - split:\n";
- // set before: no copy to itself is left, and a copy from another
header alone is not reported
+ // set before: no copy to itself is left, and the copy from a header
that nothing sets is a read of it
assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace(split,
" - setHeader:\n name: sku\n
simple: \"${body[lines][0][sku]}\"\n" + split)))
- .isEmpty();
+ .singleElement().asString()
+ .startsWith("Line 34: ${header.qty} is null here: no step
before this line sets the header qty");
// a step that may set any header before it: a to:, an enrich, a
process, a script
for (String step : List.of("- to: direct:prepare", "- enrich:\n
expression:\n constant: direct:prepare",
"- process:\n ref: prepare", "- setBody:\n groovy:
\"headers.sku = 'A'; body\"")) {
@@ -319,11 +325,154 @@ class HeaderPropertyMixupsTest {
}
// r27l-6, a passed step: the order id copied to itself, and the log
reads the variable: the null is never read
- assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace("name:
sku", "name: orderId")
- .replace("expression: \"${header.sku}\"", "expression:
\"${header.orderId}\""))).isEmpty();
- // a header of Camel itself, which the consumer sets
+ assertThat(HeaderPropertyMixups.validate(R27L6)).isEmpty();
+ // a header of Camel itself, which the consumer sets: only the qty
that nothing sets is left
assertThat(HeaderPropertyMixups.validate(CHECK_STOCK.replace("name:
sku", "name: CamelFileName")
- .replace("expression: \"${header.sku}\"", "expression:
\"${header.CamelFileName}\""))).isEmpty();
+ .replace("${header.sku}",
"${header.CamelFileName}"))).singleElement().asString()
+ .startsWith("Line 31: ${header.qty} is null here");
+ }
+
+ /** r27l-6 connect-http-client step 2, a passed step (shortened). */
+ private static final String R27L6 = """
+ - route:
+ id: check-stock
+ from:
+ uri: file
+ parameters:
+ directoryName: orders
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - setVariable:
+ name: orderId
+ simple: "${body[orderId]}"
+ - split:
+ simple: "${body[lines]}"
+ steps:
+ - setHeader:
+ name: orderId
+ simple: "${header.orderId}"
+ - setHeader:
+ name: sku
+ simple: "${body[sku]}"
+ - setHeader:
+ name: needed
+ simple: "${body[qty]}"
+ - setBody:
+ simple: "${null}"
+ - toD:
+ uri: "http://localhost:8080/stock/${header.sku}"
+ - log:
+ message: "${variable.orderId}: ${header.sku} x
${header.needed}, ${body[qty]} in stock"
+ """;
+
+ /**
+ * r28l-6 connect-http-client step 1 [13] (shortened): the body emptied
for the GET, then a header that nothing sets
+ * in the uri. Every line called http://localhost:8080/stock/: 100 errors
of 404 (r28l-7 and r26l-7 the same).
+ */
+ private static final String STOCK_CALL = """
+ - route:
+ id: check-stock
+ from:
+ uri: file
+ parameters:
+ directoryName: orders
+ noop: true
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - split:
+ expression:
+ simple:
+ expression: "${body[lines]}"
+ steps:
+ - setBody:
+ simple:
+ expression: "${null}"
+ - toD:
+ uri: "http://localhost:8080/stock/${header.sku}"
+ - log:
+ message: "Stock answer: ${body}"
+ """;
+
+ private static final String SET_BODY_NULL
+ = " - setBody:\n simple:\n
expression: \"${null}\"\n";
+
+ @Test
+ void aHeaderReadInASplitThatNothingSets(@TempDir Path dir) throws
IOException {
+ Files.createDirectories(dir.resolve("orders"));
+ Files.writeString(dir.resolve("orders/order-1001.json"), """
+ {"orderId": "ORD-1001", "lines": [{"sku": "CAMEL-MUG", "qty":
1}]}
+ """);
+ assertThat(SourceValidator.validate("http-client.camel.yaml",
STOCK_CALL, new DefaultCamelCatalog(), null, dir))
+ .singleElement().isEqualTo(
+ "Line 21: ${header.sku} is null here: no step before
this line sets the header sku. In the split"
+ + " the body is one of the lines of
the files in orders/ (qty, sku), until setBody"
+ + " on line 17 replaces it: keep
sku in a variable first, as the first step of the"
+ + " split: setVariable: {name: sku,
simple: \"${body[sku]}\"}, and write"
+ + " ${variable.sku}");
+ // the path parameter {sku} of the rest is a header of the route the
rest calls, not of this one
+ assertThat(HeaderPropertyMixups.validate(STOCK_CALL +
REST)).singleElement().asString()
+ .startsWith("Line 21: ${header.sku} is null here");
+ // the body is still the line
+ assertThat(SourceValidator.validate("http-client.camel.yaml",
STOCK_CALL.replace(SET_BODY_NULL, ""),
+ new DefaultCamelCatalog(), null,
dir)).singleElement().asString()
+ .endsWith("sku. In the split the body is one of the lines of
the files in orders/ (qty, sku): write"
+ + " ${body[sku]}");
+ // a field of the order, not of a line
+ assertThat(SourceValidator.validate("http-client.camel.yaml",
STOCK_CALL.replace("${header.sku}",
+ "${header.orderId}"), new DefaultCamelCatalog(), null,
dir)).singleElement().asString()
+ .endsWith("orderId is a field of the data that was split, not
of one of the lines: read it before the"
+ + " split, into a variable (setVariable), and write
${variable.orderId} in the split");
+ }
+
+ @Test
+ void aHeaderReadInASplitIsNotReportedWhereItCanBeSet() {
+ String toD = " - toD:\n";
+ // set before the read
+ assertThat(HeaderPropertyMixups.validate(STOCK_CALL.replace(toD,
+ " - setHeader:\n name: sku\n
simple: \"${body[sku]}\"\n"
+ +
toD)))
+ .isEmpty();
+ // a step that may set any header before the read: a to:, a process, a
script
+ for (String step : List.of("- to: direct:prepare", "- process:\n
ref: prepare",
+ "- setBody:\n groovy: \"headers.sku = 'A'; body\"")) {
+ assertThat(HeaderPropertyMixups.validate(STOCK_CALL.replace(toD,
step.indent(14) + toD))).as(step)
+ .isEmpty();
+ }
+ // a consumer whose messages, or callers, can carry the header
+ for (String from : List.of("direct:check", "seda:check",
"platform-http:/check", "kafka:orders")) {
+ assertThat(HeaderPropertyMixups.validate(STOCK_CALL.replace("uri:
file", "uri: " + from))).as(from)
+ .isEmpty();
+ }
+ // a header of Camel, and one of the job that a quartz consumer sets
+
assertThat(HeaderPropertyMixups.validate(STOCK_CALL.replace("${header.sku}",
"${header.CamelSplitIndex}")))
+ .isEmpty();
+ assertThat(HeaderPropertyMixups.validate(STOCK_CALL.replace("uri:
file", "uri: quartz:nightly")
+ .replace("${header.sku}", "${header.fireTime}"))).isEmpty();
+ // outside a split
+ assertThat(HeaderPropertyMixups.validate("- route:\n from:\n
uri: file:orders\n steps:\n"
+ + " - log:
\"${header.sku}\"\n"))
+ .isEmpty();
+ // the reference of step 2: the line's fields kept in variables before
the body is emptied
+ assertThat(HeaderPropertyMixups.validate(STOCK_CALL
+ .replace(SET_BODY_NULL, " - setVariable:\n
name: sku\n"
+ + " simple:
\"${body[sku]}\"\n" + SET_BODY_NULL)
+ .replace("${header.sku}", "${variable.sku}"))).isEmpty();
+ }
+
+ @Test
+ void aHeaderReadInASplitWhereTheRouteKeepsAVariableIsOneMessage() {
+ String variable = STOCK_CALL.replace(SET_BODY_NULL, " -
setVariable:\n name: sku\n"
+ + "
simple: \"${body[sku]}\"\n" + SET_BODY_NULL);
+
assertThat(HeaderPropertyMixups.validate(variable)).singleElement().asString()
+ .startsWith("Line 24: ${header.sku} reads a header sku, but
the route keeps sku in a variable");
+ // with the path parameter of the rest, which the mix-up check counts
as a header
+ assertThat(HeaderPropertyMixups.validate(variable +
REST)).singleElement().asString()
+ .startsWith("Line 24: ${header.sku} is null here")
+ .endsWith("The route keeps sku in a variable: write
${variable.sku}");
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RemoteFileLoginsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RemoteFileLoginsTest.java
new file mode 100644
index 000000000000..19f4f0f037f7
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RemoteFileLoginsTest.java
@@ -0,0 +1,194 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * An sftp or ftp endpoint without a login, while another endpoint of the same
server logs in: it logs in as anonymous.
+ */
+class RemoteFileLoginsTest {
+
+ private static CamelCatalog catalog;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ /** The from: of connect-sftp-transfer, which logs in with the properties
of the project. */
+ private static final String FROM = """
+ - route:
+ id: invoice-batches
+ from:
+ uri: sftp
+ parameters:
+ host: "{{sftp.host}}"
+ port: "{{sftp.port}}"
+ directoryName: invoices
+ username: "{{sftp.username}}"
+ password: "{{sftp.password}}"
+ useUserKnownHostsFile: false
+ noop: true
+ moveFailed: failed
+ steps:
+ - log:
+ message: "Downloaded ${file:name}"
+ - setBody:
+ simple: "Received ${file:name}"
+ """;
+
+ private static final String PROPERTIES = """
+ sftp.host=localhost
+ sftp.port=2222
+ sftp.username=admin
+ sftp.password=admin
+ """;
+
+ /** r28n-1 connect-sftp-transfer step 3 [15] (shortened): the receipt
uploaded to the same server, as anonymous. */
+ @Test
+ void aToWithoutALoginToTheServerOfTheFrom() {
+ String yaml = FROM + """
+ - to:
+ uri: "sftp://{{sftp.host}}:{{sftp.port}}/receipts"
+ parameters:
+ fileName: "${file:name.noext}.txt"
+ """;
+ assertThat(RemoteFileLogins.validate(yaml, catalog, null,
null)).singleElement().isEqualTo(
+ "Line 20: to: sftp://{{sftp.host}}:{{sftp.port}}/receipts has
no username, so it logs in as anonymous,"
+
+ " while the from: on line 4 logs in to the same server:
add the same login under the"
+
+ " parameters: of this to: username:
\"{{sftp.username}}\", password:"
+
+ " \"{{sftp.password}}\", useUserKnownHostsFile: false");
+ }
+
+ /** r28n-5 step 3 [3]: the host and port written out, the from: with the
placeholders of the project. */
+ @Test
+ void theHostOfThePropertiesIsTheSameServer(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve("application.properties"), PROPERTIES);
+ String yaml = FROM + """
+ - to:
+ uri: sftp://localhost:2222/receipts
+ parameters:
+ fileName: "${file:name.noext}.txt"
+ """;
+ assertThat(SourceValidator.validate("sftp-transfer.camel.yaml", yaml,
catalog, null, dir)).singleElement()
+ .asString()
+ .startsWith("Line 20: to: sftp://localhost:2222/receipts has
no username, so it logs in as anonymous,"
+ + " while the from: on line 4 logs in to the same
server");
+ // without the project its placeholders are not known: not the same
server as far as the check can tell
+ assertThat(RemoteFileLogins.validate(yaml, catalog, null,
null)).isEmpty();
+ // another port is another server
+ assertThat(RemoteFileLogins.validate(yaml.replace("localhost:2222",
"localhost:2223"), catalog, dir,
+ "sftp-transfer.camel.yaml")).isEmpty();
+ }
+
+ /** r28n-5 step 2 [23] (shortened): an onException moves a failed batch
with a to: of the uri alone. */
+ @Test
+ void aToOfTheUriAloneGetsTheWholeEndpoint(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve("application.properties"), PROPERTIES);
+ String yaml = """
+ - onException:
+ exception:
+ - java.lang.Exception
+ steps:
+ - to:
+ uri: sftp://localhost:2222/invoices
+ """ + FROM;
+ assertThat(RemoteFileLogins.validate(yaml, catalog, dir,
"sftp-transfer.camel.yaml")).singleElement().asString()
+ .startsWith("Line 6: to: sftp://localhost:2222/invoices has no
username")
+ .endsWith(": write it with the same login: to: {uri:
\"sftp://localhost:2222/invoices\", parameters:"
+ + " {username: \"{{sftp.username}}\", password:
\"{{sftp.password}}\","
+ + " useUserKnownHostsFile: false}}");
+ }
+
+ /** r26n-6 step 3 [7]: the options in the uri. */
+ @Test
+ void optionsInTheUriGetTheLoginInTheUri() {
+ String yaml = FROM + """
+ - to:
+ uri:
"sftp://{{sftp.host}}:{{sftp.port}}/receipts?fileName=${file:name.noext}.txt"
+ """;
+ assertThat(RemoteFileLogins.validate(yaml, catalog, null,
null)).singleElement().asString()
+ .endsWith(": add the same login to its uri:
&username={{sftp.username}}&password={{sftp.password}}"
+ + "&useUserKnownHostsFile=false");
+ }
+
+ @Test
+ void theFromOfAnotherRouteFileOfTheProject(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve("application.properties"), PROPERTIES);
+ Files.writeString(dir.resolve("download.camel.yaml"), FROM);
+ String yaml = """
+ - route:
+ from:
+ uri: direct:receipt
+ steps:
+ - to:
+ uri: "sftp:{{sftp.host}}:{{sftp.port}}/receipts"
+ """;
+ assertThat(RemoteFileLogins.validate(yaml, catalog, dir,
"upload.camel.yaml")).singleElement().asString()
+ .contains("while the from: of download.camel.yaml (line 4)
logs in to the same server");
+ }
+
+ @Test
+ void notReportedWhereTheLoginIsThereOrNotKnown(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve("application.properties"), PROPERTIES);
+ String to = """
+ - to:
+ uri: "%s"
+ """;
+ for (String uri : List.of(
+ // a login of its own, in the uri or under parameters: below
+ "sftp://admin@{{sftp.host}}:{{sftp.port}}/receipts",
+
"sftp://{{sftp.host}}:{{sftp.port}}/receipts?username={{sftp.username}}&password={{sftp.password}}",
+ // another server, another scheme
+ "sftp://files.example.com/receipts",
"ftp://{{sftp.host}}:{{sftp.port}}/receipts",
+ // the whole uri a placeholder
+ "{{receipts.uri}}")) {
+ assertThat(RemoteFileLogins.validate(FROM + to.formatted(uri),
catalog, dir, "sftp-transfer.camel.yaml"))
+ .as(uri).isEmpty();
+ }
+ // the reference of the step: the same login under parameters
+ String reference = FROM + """
+ - to:
+ uri: sftp
+ parameters:
+ host: "{{sftp.host}}"
+ port: "{{sftp.port}}"
+ directoryName: receipts
+ username: "{{sftp.username}}"
+ password: "{{sftp.password}}"
+ useUserKnownHostsFile: false
+ fileName: "${file:name.noext}.txt"
+ """;
+ assertThat(RemoteFileLogins.validate(reference, catalog, dir,
"sftp-transfer.camel.yaml")).isEmpty();
+ // no endpoint of the server logs in: nothing to copy, and an
anonymous server is possible
+ String anonymous = FROM.replace(" username:
\"{{sftp.username}}\"\n", "")
+ .replace(" password: \"{{sftp.password}}\"\n", "") +
to.formatted("sftp://localhost:2222/receipts");
+ assertThat(RemoteFileLogins.validate(anonymous, catalog, dir,
"sftp-transfer.camel.yaml")).isEmpty();
+ }
+}