content: /*
   * Licensed to the Apache Software Foundation (ASF) under one or more
   * contributor license agreements.  See the NOTICE file distributed with
   * this work for additional information regarding copyright ownership.
   * The ASF licenses this file to You under the Apache License, Version 2.0
   * (the "License"); you may not use this file except in compliance with
   * the License.  You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  package org.apache.camel.component.scp;
  
  import java.io.BufferedInputStream;
  import java.io.ByteArrayInputStream;
  import java.io.ByteArrayOutputStream;
  import java.io.IOException;
  import java.io.InputStream;
  import java.io.OutputStream;
  import java.util.Arrays;
  import java.util.Hashtable;
  
  import com.jcraft.jsch.ChannelExec;
  import com.jcraft.jsch.JSch;
  import com.jcraft.jsch.JSchException;
  import com.jcraft.jsch.Session;
  import com.jcraft.jsch.UIKeyboardInteractive;
  import com.jcraft.jsch.UserInfo;
  import org.apache.camel.Exchange;
  import org.apache.camel.InvalidPayloadException;
  import org.apache.camel.component.file.GenericFile;
  import org.apache.camel.component.file.GenericFileEndpoint;
  import org.apache.camel.component.file.GenericFileOperationFailedException;
  import org.apache.camel.component.file.remote.RemoteFile;
  import org.apache.camel.component.file.remote.RemoteFileConfiguration;
  import org.apache.camel.component.file.remote.RemoteFileOperations;
  import org.apache.camel.support.ResourceHelper;
  import org.apache.camel.util.IOHelper;
  import org.apache.camel.util.ObjectHelper;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  
  /**
   * SCP remote file operations
   */
  public class ScpOperations implements RemoteFileOperations<ScpFile> {
      private static final Logger LOG = LoggerFactory.getLogger(ScpOperations.class);
      private static final byte[] EMPTY = {};
  
      private ScpEndpoint endpoint;
      private Session session;
      private ChannelExec channel;
      private String userKnownHostFile;
  
      @Override
      public GenericFile<ScpFile> newGenericFile() {
          return new RemoteFile<>();
      }
  
      @Override
      public void setEndpoint(GenericFileEndpoint<ScpFile> endpoint) {
          this.endpoint = (ScpEndpoint) endpoint;
      }
  
      @Override
      public boolean deleteFile(String name) throws GenericFileOperationFailedException {
          throw new GenericFileOperationFailedException("Operation 'delete' not supported by the scp: protocol");
      }
  
      @Override
      public boolean existsFile(String name) throws GenericFileOperationFailedException {
          // maybe... cannot determine using the scp: protocol
          return false;
      }
  
      @Override
      public boolean renameFile(String from, String to) throws GenericFileOperationFailedException {
          throw new GenericFileOperationFailedException("Operation 'rename' not supported by the scp: protocol");
      }
  
      @Override
      public boolean buildDirectory(String directory, boolean absolute) throws GenericFileOperationFailedException {
          // done by the server
          return true;
      }
  
      @Override
      public boolean retrieveFile(String name, Exchange exchange, long isze) throws GenericFileOperationFailedException {
          return false;
      }
  
      @Override
      public void releaseRetrievedFileResources(Exchange exchange) throws GenericFileOperationFailedException {
          // noop
      }
  
      @Override
      public boolean storeFile(String name, Exchange exchange, long size) throws GenericFileOperationFailedException {
          ObjectHelper.notNull(session, "session");
          ScpConfiguration cfg = endpoint.getConfiguration();
  
          int timeout = cfg.getConnectTimeout();
          if (LOG.isTraceEnabled()) {
              LOG.trace("Opening channel to {} with {} timeout...", cfg.remoteServerInformation(),
                      timeout > 0 ? (Integer.toString(timeout) + " ms") : "no");
          }
          String file = getRemoteFile(name, cfg);
  
          InputStream is = null;
          if (exchange.getIn().getBody() == null) {
              // Do an explicit test for a null body and decide what to do
              if (endpoint.isAllowNullBody()) {
                  LOG.trace("Writing empty file.");
                  is = new ByteArrayInputStream(EMPTY);
              } else {
                  throw new GenericFileOperationFailedException("Cannot write null body to file: " + name);
              }
          }
  
          try {
              channel = (ChannelExec) session.openChannel("exec");
              channel.setCommand(getScpCommand(cfg, file));
              channel.connect(timeout);
              if (LOG.isTraceEnabled()) {
                  LOG.trace("Channel connected to {}", cfg.remoteServerInformation());
              }
  
              try {
                  if (is == null) {
                      is = exchange.getIn().getMandatoryBody(InputStream.class);
                  }
                  write(channel, file, is, cfg);
              } catch (InvalidPayloadException e) {
                  throw new GenericFileOperationFailedException("Cannot store file: " + name, e);
              } catch (IOException e) {
                  throw new GenericFileOperationFailedException("Failed to write file " + file, e);
              } finally {
                  // must close stream after usage
                  IOHelper.close(is);
              }
          } catch (JSchException e) {
              throw new GenericFileOperationFailedException("Failed to write file " + file, e);
          } finally {
              if (channel != null) {
                  LOG.trace("Disconnecting 'exec' scp channel");
                  channel.disconnect();
                  channel = null;
  
                  if (LOG.isTraceEnabled()) {
                      LOG.trace("Channel disconnected from {}", cfg.remoteServerInformation());
                  }
              }
          }
          return true;
      }
  
      @Override
      public String getCurrentDirectory() throws GenericFileOperationFailedException {
          return endpoint.getConfiguration().getDirectory();
      }
  
      @Override
      public void changeCurrentDirectory(String path) throws GenericFileOperationFailedException {
          throw new GenericFileOperationFailedException("Operation 'cd " + path + "' not supported by the scp: protocol");
      }
  
      @Override
      public void changeToParentDirectory() throws GenericFileOperationFailedException {
          throw new GenericFileOperationFailedException("Operation 'cd ..' not supported by the scp: protocol");
      }
  
      @Override
      public ScpFile[] listFiles() throws GenericFileOperationFailedException {
          throw new GenericFileOperationFailedException("Operation 'ls' not supported by the scp: protocol");
      }
  
      @Override
      public ScpFile[] listFiles(String path) throws GenericFileOperationFailedException {
          throw new GenericFileOperationFailedException("Operation 'ls " + path + "' not supported by the scp: protocol");
      }
  
      @Override
      public boolean connect(RemoteFileConfiguration configuration, Exchange exchange)
              throws GenericFileOperationFailedException {
          if (!isConnected()) {
              session = createSession(configuration instanceof ScpConfiguration ? (ScpConfiguration) configuration : null);
              // TODO: deal with reconnection attempts
              if (!isConnected()) {
                  session = null;
                  throw new GenericFileOperationFailedException(
                          "Failed to connect to " + configuration.remoteServerInformation());
              }
          }
          return true;
      }
  
      @Override
      public boolean isConnected() throws GenericFileOperationFailedException {
          return session != null && session.isConnected();
      }
  
      @Override
      public void disconnect() throws GenericFileOperationFailedException {
          try {
              if (isConnected()) {
                  session.disconnect();
              }
          } finally {
              session = null;
          }
      }
  
      @Override
      public void forceDisconnect() throws GenericFileOperationFailedException {
          try {
              session.disconnect();
          } finally {
              session = null;
          }
      }
  
      @Override
      public boolean sendNoop() throws GenericFileOperationFailedException {
          // not supported by scp:
          return true;
      }
  
      @Override
      public boolean sendSiteCommand(String command) throws GenericFileOperationFailedException {
          return true;
      }
  
      private Session createSession(ScpConfiguration config) {
          ObjectHelper.notNull(config, "ScpConfiguration");
          try {
              final JSch jsch = new JSch();
              // get from configuration
              if (ObjectHelper.isNotEmpty(config.getCiphers())) {
                  LOG.trace("Using ciphers: {}", config.getCiphers());
                  Hashtable<String, String> ciphers = new Hashtable<>();
                  ciphers.put("cipher.s2c", config.getCiphers());
                  ciphers.put("cipher.c2s", config.getCiphers());
                  JSch.setConfig(ciphers);
              }
              if (ObjectHelper.isNotEmpty(config.getPrivateKeyFile())) {
                  LOG.trace("Using private keyfile: {}", config.getPrivateKeyFile());
                  String pkfp = config.getPrivateKeyFilePassphrase();
  
                  String name = config.getPrivateKeyFile();
                  // load from file system by default
                  if (!name.startsWith("classpath:")) {
                      name = "file:" + name;
                  }
                  try (InputStream is = ResourceHelper.resolveMandatoryResourceAsInputStream(endpoint.getCamelContext(), name)) {
                      byte[] data = endpoint.getCamelContext().getTypeConverter().mandatoryConvertTo(byte[].class, is);
                      jsch.addIdentity("camel-jsch", data, null, pkfp != null ? pkfp.getBytes() : null);
                  } catch (Exception e) {
                      throw new GenericFileOperationFailedException(
                              "Cannot load private keyfile: " + config.getPrivateKeyFile(), e);
                  }
              } else if (ObjectHelper.isNotEmpty(config.getPrivateKeyBytes())) {
                  LOG.trace("Using private key bytes: {}", config.getPrivateKeyBytes());
  
                  String pkfp = config.getPrivateKeyFilePassphrase();
  
                  byte[] data = config.getPrivateKeyBytes();
  
                  try {
                      jsch.addIdentity("camel-jsch", data, null, pkfp != null ? pkfp.getBytes() : null);
                  } catch (Exception e) {
                      throw new GenericFileOperationFailedException(
                              "Cannot load private key bytes: " + Arrays.toString(config.getPrivateKeyBytes()), e);
                  }
              }
  
              String knownHostsFile = config.getKnownHostsFile();
              if (knownHostsFile == null && config.isUseUserKnownHostsFile()) {
                  if (userKnownHostFile == null) {
                      userKnownHostFile = System.getProperty("user.home") + "/.ssh/known_hosts";
                      LOG.info("Known host file not configured, using user known host file: {}", userKnownHostFile);
                  }
                  knownHostsFile = userKnownHostFile;
              }
              // load file as input stream which can then load from classpath etc
              if (ObjectHelper.isNotEmpty(knownHostsFile)) {
                  // load from file system by default
                  if (!knownHostsFile.startsWith("classpath:")) {
                      knownHostsFile = "file:" + knownHostsFile;
                  }
                  try {
                      InputStream is
                              = ResourceHelper.resolveMandatoryResourceAsInputStream(endpoint.getCamelContext(), knownHostsFile);
                      jsch.setKnownHosts(is);
                  } catch (Exception e) {
                      throw new GenericFileOperationFailedException("Cannot load known host file: " + knownHostsFile, e);
                  }
              } else {
                  jsch.setKnownHosts((String) null);
              }
              session = jsch.getSession(config.getUsername(), config.getHost(), config.getPort());
              session.setTimeout(config.getTimeout());
              session.setUserInfo(new SessionUserInfo(config));
  
              if (ObjectHelper.isNotEmpty(config.getStrictHostKeyChecking())) {
                  LOG.trace("Using StrickHostKeyChecking: {}", config.getStrictHostKeyChecking());
                  session.setConfig("StrictHostKeyChecking", config.getStrictHostKeyChecking());
              }
  
              if (ObjectHelper.isNotEmpty(config.getPreferredAuthentications())) {
                  LOG.trace("Using preferredAuthentications: {}", config.getPreferredAuthentications());
                  session.setConfig("PreferredAuthentications", config.getPreferredAuthentications());
              }
  
              int timeout = config.getConnectTimeout();
  
              if (LOG.isDebugEnabled()) {
                  LOG.debug("Connecting to {} with {} timeout...", config.remoteServerInformation(),
                          timeout > 0 ? (timeout + " ms") : "no");
              }
              if (timeout > 0) {
                  session.connect(timeout);
              } else {
                  session.connect();
              }
          } catch (JSchException e) {
              session = null;
              LOG.warn("Could not create ssh session for {}", config.remoteServerInformation(), e);
          }
          return session;
      }
  
      private void write(ChannelExec c, String name, InputStream data, ScpConfiguration cfg) throws IOException {
          OutputStream os = c.getOutputStream();
          InputStream is = c.getInputStream();
  
          try {
              writeFile(name, data, os, is, cfg);
          } finally {
              IOHelper.close(is, os);
          }
      }
  
      private void writeFile(String filename, InputStream data, OutputStream os, InputStream is, ScpConfiguration cfg)
              throws IOException {
          final int lineFeed = '\n';
          String bytes;
          int pos = filename.indexOf('/');
          if (pos >= 0) {
              // write to child directory
              String dir = filename.substring(0, pos);
              bytes = "D0775 0 " + dir;
              LOG.trace("[scp:sink] {}", bytes);
              os.write(bytes.getBytes());
              os.write(lineFeed);
              os.flush();
              readAck(is);
  
              writeFile(filename.substring(pos + 1), data, os, is, cfg);
  
              bytes = "E";
              LOG.trace("[scp:sink] {}", bytes);
              os.write(bytes.getBytes());
              os.write(lineFeed);
              os.flush();
              readAck(is);
          } else {
              int count = 0;
              int read;
              int size = endpoint.getBufferSize();
              byte[] reply = new byte[size];
  
              // figure out the stream size as we need to pass it in the header
              BufferedInputStream buffer = new BufferedInputStream(data, size);
              try {
                  buffer.mark(Integer.MAX_VALUE);
                  while ((read = buffer.read(reply)) != -1) {
                      count += read;
                  }
  
                  // send the header
                  bytes = "C0" + cfg.getChmod() + " " + count + " " + filename;
                  LOG.trace("[scp:sink] {}", bytes);
                  os.write(bytes.getBytes());
                  os.write(lineFeed);
                  os.flush();
                  readAck(is);
  
                  // now send the stream
                  buffer.reset();
                  while ((read = buffer.read(reply)) != -1) {
                      os.write(reply, 0, read);
                  }
                  writeAck(os);
                  readAck(is);
              } finally {
                  IOHelper.close(buffer);
              }
          }
      }
  
      private void writeAck(OutputStream os) throws IOException {
          os.write(0);
          os.flush();
      }
  
      private int readAck(InputStream is) throws IOException {
          String message;
          int answer = is.read();
          switch (answer) {
              case 0:
                  break;
              default:
                  message = "[scp] Return Code [" + answer + "] " + readLine(is);
                  throw new IOException(message);
          }
          return answer;
      }
  
      private String readLine(InputStream is) throws IOException {
          ByteArrayOutputStream bytes = new ByteArrayOutputStream();
          try {
              int c;
              do {
                  c = is.read();
                  if (c == '\n') {
                      return bytes.toString();
                  }
                  bytes.write(c);
              } while (c != -1);
          } finally {
              IOHelper.close(bytes);
          }
  
          String message = "[scp] Unexpected end of stream";
          throw new IOException(message);
      }
  
      private static String getRemoteTarget(ScpConfiguration config) {
          // use current dir (".") if target directory not specified in uri
          return config.getDirectory().isEmpty() ? "." : config.getDirectory();
      }
  
      private static String getRemoteFile(String name, ScpConfiguration config) {
          String dir = config.getDirectory();
          dir = dir.endsWith("/") ? dir : dir + "/";
          return name.startsWith(dir) ? name.substring(dir.length()) : name;
      }
  
      private static boolean isRecursiveScp(String name) {
          return name.contains("/");
      }
  
      private static String getScpCommand(ScpConfiguration config, String name) {
          StringBuilder cmd = new StringBuilder();
          cmd.append("scp ");
          // TODO: need config for scp *-p* (preserves modification times, access times, and modes from the original file)
          // String command="scp " + (ptimestamp ? "-p " : "") + "-t " + configuration.getDirectory();
          // TODO: refactor to use generic command
          cmd.append(isRecursiveScp(name) ? "-r " : "");
          cmd.append("-t ");
          cmd.append(getRemoteTarget(config));
          return cmd.toString();
      }
  
      protected static final class SessionUserInfo implements UserInfo, UIKeyboardInteractive {
          private final ScpConfiguration config;
  
          public SessionUserInfo(ScpConfiguration config) {
              ObjectHelper.notNull(config, "config");
              this.config = config;
          }
  
          @Override
          public String getPassphrase() {
              LOG.warn("Private Key authentication not supported");
              return null;
          }
  
          @Override
          public String getPassword() {
              LOG.debug("Providing password for ssh authentication of user '{}'", config.getUsername());
              return config.getPassword();
          }
  
          @Override
          public boolean promptPassword(String message) {
              LOG.debug(message);
              return true;
          }
  
          @Override
          public boolean promptPassphrase(String message) {
              LOG.debug(message);
              return true;
          }
  
          @Override
          public boolean promptYesNo(String message) {
              LOG.debug(message);
              return false;
          }
  
          @Override
          public void showMessage(String message) {
              LOG.debug(message);
          }
  
          @Override
          public String[] promptKeyboardInteractive(
                  String destination, String name,
                  String instruction, String[] prompt, boolean[] echo) {
              LOG.debug(instruction);
              // Called for either SSH_MSG_USERAUTH_INFO_REQUEST or SSH_MSG_USERAUTH_PASSWD_CHANGEREQ
              // The most secure choice (especially for the second case) is to return null
              return null;
          }
      }
  }
  