Croway commented on PR #29848:
URL: https://github.com/apache/camel/pull/29848#issuecomment-6854139570

   _Claude Code on behalf of Croway_
   
   Thanks, escaping at the `ChannelSftp` call is the right place. I ran the new 
`SftpFileNameWithSpecialCharactersIT`: all 13 cases pass on this PR (`3a10aa0`) 
and all 13 fail on main (`9235b3c`). I also ran a reproducer on both, using the 
same embedded SFTP server (attached below). The points below come from that 
run, except the test-coverage notes in 5, which are from reading the test.
   
   **1. A name with `\`: the existence check and the delete look at different 
files, and with this PR the delete can remove an unrelated file**
   
   `lstat()` (the existence check) splits the name at the last `/` or `\` and 
joins the parts with `/`, so `inbox/sub\file.txt` is checked as 
`inbox/sub/file.txt`. The store (`normalizePath`) and the rename target 
(`compactPath`) also treat `\` as a separator. `deleteFile` now removes the 
literal `inbox/sub\file.txt`.
   
   Producer with `tempPrefix=inprogress-` and `CamelFileName=sub\file.txt`, 
sent twice. Before the second send, an unrelated file literally named 
`inbox/sub\file.txt` exists:
   
   | | 2nd send | files after |
   |---|---|---|
   | main | fails: `Cannot delete file: .../inbox/sub\file.txt` 
(SSH_FX_NO_SUCH_FILE) | `inbox/sub/file.txt=first`, 
`inbox/sub\file.txt=unrelated` |
   | PR | succeeds | `inbox/sub/file.txt=second`; **`inbox/sub\file.txt` 
deleted** |
   
   Without the unrelated file, the second send fails with `Cannot delete file` 
on both. Since every other step treats `\` as a separator, I think `deleteFile` 
(or the producer target) should use the same normalized name before quoting, 
rather than the literal one.
   
   **2. The upgrade guide says `\` is now used as given, but it isn't, on 
either main or this PR**
   
   - Producer `CamelFileName=a\b.txt` is stored as `inbox/a/b.txt`.
   - Consumer: a listed file literally named `inbox/a\b.txt` fails with `Cannot 
change directory to: a` and stays where it is.
   
   I'd limit the sentence (and the "as the other file based components do" in 
the javadoc of `quotePathForJsch`) to `*` and `?`. Two more points on the 
migration advice:
   - `include`/`antInclude` are consumer options, so they don't help someone 
who put a wildcard in the producer's `CamelFileName` header. For the producer, 
the header just has to be the real name.
   - A wildcard in the endpoint directory (`cd`/`ls` used to match it) isn't 
mentioned.
   
   **3. The 4.22.3 and 4.18.6 upgrade-guide entries belong in this PR**
   
   CAMEL-26925 has fixVersions 4.18.6 and 4.22.3, and main already has the 
`Upgrading from 4.22.2 to 4.22.3` and `Upgrading from 4.18.5 to 4.18.6` 
sections. The project convention is "For backported changes, add the entry on 
`main`", but the description says these entries will follow with the backports. 
Separately: a wildcard `fileName` stops selecting files in a patch release. Is 
that intended for the LTS branches?
   
   **4. Not introduced here, but this PR makes it the common path: a stepwise 
retrieve leaves the channel in the file's directory when `get` fails**
   
   `retrieveFileToStreamInBody` and `retrieveFileToFileInLocalWorkDirectory` 
change back to the previous directory only on success (no `finally`). Test: a 
relative endpoint directory with 
`useList=false&fileName=data-*.txt&ignoreFileNotFoundOrPermissionError=true` 
and default stepwise, plus a `data-1.txt` file:
   - main: the first poll matches `data-*.txt` and consumes `data-1.txt`.
   - PR: the `get` of the literal `data-*.txt` fails, as the upgrade guide 
describes, and the working directory stays in `.../inbox`. Every later poll 
fails with `Cannot change directory to: target`, so a file literally named 
`data-*.txt` created afterwards is never picked up.
   
   Main has the same leak once any `get` fails, so this could be its own JIRA. 
But it's exactly the setup the upgrade guide describes, so users who follow the 
migration note will hit it.
   
   **5. Tests**
   
   - `chmodOnlyChangesStoredFile` only checks that the other file's mode is 
unchanged. It should also check that the stored file got the mode, e.g. 
`assertEquals(PosixFilePermissions.fromString("rwxrwxrwx"), 
Files.getPosixFilePermissions(inbox.resolve("data-?.txt")))`, and compare the 
permission sets instead of their `toString()`.
   - Changed calls with no test: `rename` (consumer `move`/`preMove`, 
`fileExist=Move`), `localWorkDirectory`, `fileExist=Append`, the checksum file 
(`storeFileDirectly`), `chmodDirectory`, and the `cd` directory check 
(`existDirCheckUsingLs=false`).
   
   <details>
   <summary>Reproducer (<code>Pr29848ReproIT</code>, in 
<code>components/camel-ftp</code> next to 
<code>SftpExistsFileIT</code>)</summary>
   
   ```java
   package org.apache.camel.component.file.remote.sftp.integration;
   
   import java.io.IOException;
   import java.lang.reflect.Field;
   import java.nio.file.Files;
   import java.nio.file.Path;
   import java.util.List;
   import java.util.concurrent.CopyOnWriteArrayList;
   import java.util.concurrent.TimeUnit;
   import java.util.stream.Stream;
   
   import org.apache.camel.CamelExecutionException;
   import org.apache.camel.Consumer;
   import org.apache.camel.Endpoint;
   import org.apache.camel.Exchange;
   import org.apache.camel.builder.RouteBuilder;
   import org.apache.camel.component.file.remote.RemoteFileOperations;
   import org.apache.camel.component.mock.MockEndpoint;
   import org.apache.camel.spi.PollingConsumerPollStrategy;
   import org.apache.camel.test.junit6.CamelTestSupport;
   import org.junit.jupiter.api.BeforeEach;
   import org.junit.jupiter.api.Test;
   import org.junit.jupiter.api.condition.EnabledIf;
   import org.junit.jupiter.api.extension.RegisterExtension;
   import org.junit.jupiter.params.ParameterizedTest;
   import org.junit.jupiter.params.provider.ValueSource;
   
   import static org.awaitility.Awaitility.await;
   
   /**
    * Reproducers for the review of PR #29848. Each test prints what it 
observed (lines starting with REPRO).
    */
   @EnabledIf(value = 
"org.apache.camel.test.infra.ftp.services.embedded.SftpUtil#hasRequiredAlgorithms('src/test/resources/hostkey.pem')")
   class Pr29848ReproIT extends CamelTestSupport {
   
       @RegisterExtension
       SftpExistsFileIT.CountingSftpEmbeddedService service = new 
SftpExistsFileIT.CountingSftpEmbeddedService();
   
       private Path base;
       private Path inbox;
   
       @BeforeEach
       void createTestDirectory() throws IOException {
           base = 
Files.createTempDirectory(Files.createDirectories(service.getFtpRootDir()), 
"repro");
           inbox = Files.createDirectories(base.resolve("inbox"));
       }
   
       // finding: with tempPrefix the existence check uses the name literally, 
the rename target is compacted with \ as separator
       @ParameterizedTest
       @ValueSource(strings = { "\\.\\./keep.txt", "sub\\file.txt" })
       void secondSendWithTempPrefixAndBackslash(String name) throws Exception {
           String uri = sftpUri("inbox") + "&tempPrefix=inprogress-";
           template.sendBodyAndHeader(uri, "first", Exchange.FILE_NAME, name);
           System.out.println("REPRO tempPrefix name=" + name + " after 1st 
send: " + tree());
           try {
               template.sendBodyAndHeader(uri, "second", Exchange.FILE_NAME, 
name);
               System.out.println("REPRO tempPrefix name=" + name + " 2nd send 
OK: " + tree());
           } catch (CamelExecutionException e) {
               System.out.println("REPRO tempPrefix name=" + name + " 2nd send 
FAILED: " + rootMessage(e) + " ; " + tree());
           }
       }
   
       // finding: the existence check finds sub/file.txt, the delete then 
removes the file literally named sub\file.txt
       @Test
       void secondSendDeletesLiteralBackslashFile() throws Exception {
           String uri = sftpUri("inbox") + "&tempPrefix=inprogress-";
           template.sendBodyAndHeader(uri, "first", Exchange.FILE_NAME, 
"sub\\file.txt");
           Files.writeString(inbox.resolve("sub\\file.txt"), "unrelated");
           System.out.println("REPRO victim before 2nd send: " + tree());
           try {
               template.sendBodyAndHeader(uri, "second", Exchange.FILE_NAME, 
"sub\\file.txt");
               System.out.println("REPRO victim 2nd send OK: " + tree());
           } catch (CamelExecutionException e) {
               System.out.println("REPRO victim 2nd send FAILED: " + 
rootMessage(e) + " ; " + tree());
           }
       }
   
       // finding: a producer name with \ is not stored literally
       @Test
       void producerBackslashName() throws Exception {
           template.sendBodyAndHeader(sftpUri("inbox"), "stored", 
Exchange.FILE_NAME, "a\\b.txt");
           System.out.println("REPRO producer a\\b.txt stored as: " + tree());
       }
   
       // finding: a listed file whose name contains \ is not retrieved 
literally
       @Test
       void consumerBackslashName() throws Exception {
           Files.writeString(inbox.resolve("a\\b.txt"), "wanted");
           MockEndpoint result = getMockEndpoint("mock:result");
           MockEndpoint error = getMockEndpoint("mock:error");
           context.addRoutes(new RouteBuilder() {
               @Override
               public void configure() {
                   onException(Exception.class).handled(true).to("mock:error");
                   from(sftpUri("inbox") + 
"&delete=true&initialDelay=0&delay=60000&bridgeErrorHandler=true")
                           .convertBodyTo(String.class).to("mock:result");
               }
           });
           await().atMost(10, TimeUnit.SECONDS)
                   .until(() -> result.getReceivedCounter() + 
error.getReceivedCounter() > 0);
           if (result.getReceivedCounter() > 0) {
               Exchange e = result.getReceivedExchanges().get(0);
               System.out.println("REPRO consumer a\\b.txt RECEIVED body=" + 
e.getMessage().getBody()
                                  + " CamelFileName=" + 
e.getMessage().getHeader(Exchange.FILE_NAME));
           } else {
               Exception ex = 
error.getReceivedExchanges().get(0).getProperty(Exchange.EXCEPTION_CAUGHT, 
Exception.class);
               System.out.println("REPRO consumer a\\b.txt ERROR: " + 
rootMessage(ex));
           }
           System.out.println("REPRO consumer a\\b.txt files after: " + tree());
       }
   
       // finding: stepwise retrieve does not change back to the original 
directory when get fails
       @Test
       void stepwiseRetrieveFailureLeavesDirectory() throws Exception {
           Files.writeString(inbox.resolve("data-1.txt"), "other");
           // base is relative to the home directory of the server (the module 
directory)
           String relative = base.resolve("inbox").toString();
           List<String> dirs = new CopyOnWriteArrayList<>();
           List<String> failures = new CopyOnWriteArrayList<>();
           context.getRegistry().bind("ps", new PollingConsumerPollStrategy() {
               @Override
               public boolean begin(Consumer consumer, Endpoint endpoint) {
                   dirs.add(currentDirectory(consumer));
                   return true;
               }
   
               @Override
               public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
               }
   
               @Override
               public boolean rollback(Consumer consumer, Endpoint endpoint, 
int retryCounter, Exception e) {
                   failures.add(rootMessage(e));
                   return false;
               }
           });
           MockEndpoint result = getMockEndpoint("mock:result");
           context.addRoutes(new RouteBuilder() {
               @Override
               public void configure() {
                   from("sftp://localhost:"; + service.getPort() + "/" + relative
                        + "?username=admin&password=admin&knownHostsFile=" + 
service.getKnownHostsFile()
                        + 
"&useList=false&fileName=data-*.txt&ignoreFileNotFoundOrPermissionError=true"
                        + "&initialDelay=0&delay=200&pollStrategy=#ps")
                           .convertBodyTo(String.class).to("mock:result");
               }
           });
           await().atMost(10, TimeUnit.SECONDS).until(() -> dirs.size() >= 4 || 
result.getReceivedCounter() > 0);
           // the literal file now appears: is it picked up?
           Files.writeString(inbox.resolve("data-*.txt"), "wanted");
           await().atMost(10, TimeUnit.SECONDS).until(() -> dirs.size() >= 12);
           System.out.println("REPRO stepwise relative dir=" + relative);
           System.out.println("REPRO stepwise current directory at the start of 
each poll: " + dirs.subList(0, 6));
           System.out.println("REPRO stepwise poll failures: " + 
failures.stream().distinct().toList());
           System.out.println("REPRO stepwise received bodies: "
                              + result.getReceivedExchanges().stream().map(x -> 
x.getMessage().getBody()).distinct().toList());
       }
   
       private static String currentDirectory(Consumer consumer) {
           try {
               Class<?> c = consumer.getClass();
               while (c != null) {
                   try {
                       Field f = c.getDeclaredField("operations");
                       f.setAccessible(true);
                       return ((RemoteFileOperations<?>) 
f.get(consumer)).getCurrentDirectory();
                   } catch (NoSuchFieldException e) {
                       c = c.getSuperclass();
                   }
               }
               return "?";
           } catch (Exception e) {
               return "not connected (" + e.getMessage() + ")";
           }
       }
   
       private String tree() throws IOException {
           try (Stream<Path> s = Files.walk(base)) {
               return s.filter(Files::isRegularFile).map(p -> {
                   try {
                       return base.relativize(p) + "=" + Files.readString(p);
                   } catch (IOException e) {
                       return base.relativize(p) + "=?";
                   }
               }).sorted().toList().toString();
           }
       }
   
       private static String rootMessage(Throwable t) {
           StringBuilder sb = new StringBuilder();
           while (t != null) {
               sb.append(t.getClass().getSimpleName()).append(": 
").append(t.getMessage()).append(" <- ");
               t = t.getCause() == t ? null : t.getCause();
           }
           return sb.toString();
       }
   
       private String sftpUri(String directory) {
           return "sftp://localhost:"; + service.getPort() + "/" + base + "/" + 
directory
                  + "?username=admin&password=admin&knownHostsFile=" + 
service.getKnownHostsFile();
       }
   }
   ```
   
   </details>
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to