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();
+    }
+}

Reply via email to