This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git
The following commit(s) were added to refs/heads/master by this push:
new 5174788 [SSHD-1009] SSHD SCP does not work with WinSCP
5174788 is described below
commit 51747885f9f4fa5ccffb4f29e573c6c0f363eadc
Author: Guillaume Nodet <[email protected]>
AuthorDate: Wed Jun 3 21:58:57 2020 +0200
[SSHD-1009] SSHD SCP does not work with WinSCP
---
.../apache/sshd/server/scp/InputStreamReader.java | 346 ++++++++++++
.../apache/sshd/server/scp/ScpCommandFactory.java | 19 +-
.../java/org/apache/sshd/server/scp/ScpShell.java | 588 +++++++++++++++++++++
.../java/org/apache/sshd/client/scp/ScpTest.java | 4 +-
4 files changed, 953 insertions(+), 4 deletions(-)
diff --git
a/sshd-scp/src/main/java/org/apache/sshd/server/scp/InputStreamReader.java
b/sshd-scp/src/main/java/org/apache/sshd/server/scp/InputStreamReader.java
new file mode 100644
index 0000000..cff78e9
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/server/scp/InputStreamReader.java
@@ -0,0 +1,346 @@
+/*
+ * Copyright (c) 2002-2016, the original author or authors.
+ *
+ * This software is distributable under the BSD license. See the terms of the
+ * BSD license in the documentation provided with this software.
+ *
+ * https://opensource.org/licenses/BSD-3-Clause
+ */
+package org.apache.sshd.server.scp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.MalformedInputException;
+import java.nio.charset.UnmappableCharacterException;
+
+
+/**
+ *
+ * NOTE for SSHD: the default InputStreamReader that comes from the JRE
+ * usually read more bytes than needed from the input stream, which
+ * is not usable in a character per character model used in the terminal.
+ * We thus use the harmony code which only reads the minimal number of bytes.
+ */
+
+/**
+ * A class for turning a byte stream into a character stream. Data read from
the
+ * source input stream is converted into characters by either a default or a
+ * provided character converter. The default encoding is taken from the
+ * "file.encoding" system property. {@code InputStreamReader} contains a buffer
+ * of bytes read from the source stream and converts these into characters as
+ * needed. The buffer size is 8K.
+ *
+ * @see OutputStreamWriter
+ */
+public class InputStreamReader extends Reader {
+ private InputStream in;
+
+ private static final int BUFFER_SIZE = 4;
+
+ private boolean endOfInput = false;
+
+ CharsetDecoder decoder;
+
+ ByteBuffer bytes = ByteBuffer.allocate(BUFFER_SIZE);
+
+ char pending = (char) -1;
+
+ /**
+ * Constructs a new {@code InputStreamReader} on the {@link InputStream}
+ * {@code in}. This constructor sets the character converter to the
encoding
+ * specified in the "file.encoding" property and falls back to ISO 8859_1
+ * (ISO-Latin-1) if the property doesn't exist.
+ *
+ * @param in
+ * the input stream from which to read characters.
+ */
+ public InputStreamReader(InputStream in) {
+ super(in);
+ this.in = in;
+ decoder = Charset.defaultCharset().newDecoder().onMalformedInput(
+ CodingErrorAction.REPLACE).onUnmappableCharacter(
+ CodingErrorAction.REPLACE);
+ bytes.limit(0);
+ }
+
+ /**
+ * Constructs a new InputStreamReader on the InputStream {@code in}. The
+ * character converter that is used to decode bytes into characters is
+ * identified by name by {@code enc}. If the encoding cannot be found, an
+ * UnsupportedEncodingException error is thrown.
+ *
+ * @param in
+ * the InputStream from which to read characters.
+ * @param enc
+ * identifies the character converter to use.
+ * @throws NullPointerException
+ * if {@code enc} is {@code null}.
+ * @throws UnsupportedEncodingException
+ * if the encoding specified by {@code enc} cannot be found.
+ */
+ public InputStreamReader(InputStream in, final String enc)
+ throws UnsupportedEncodingException {
+ super(in);
+ if (enc == null) {
+ throw new NullPointerException();
+ }
+ this.in = in;
+ try {
+ decoder = Charset.forName(enc).newDecoder().onMalformedInput(
+ CodingErrorAction.REPLACE).onUnmappableCharacter(
+ CodingErrorAction.REPLACE);
+ } catch (IllegalArgumentException e) {
+ throw (UnsupportedEncodingException)
+ new UnsupportedEncodingException(enc).initCause(e);
+ }
+ bytes.limit(0);
+ }
+
+ /**
+ * Constructs a new InputStreamReader on the InputStream {@code in} and
+ * CharsetDecoder {@code dec}.
+ *
+ * @param in
+ * the source InputStream from which to read characters.
+ * @param dec
+ * the CharsetDecoder used by the character conversion.
+ */
+ public InputStreamReader(InputStream in, CharsetDecoder dec) {
+ super(in);
+ dec.averageCharsPerByte();
+ this.in = in;
+ decoder = dec;
+ bytes.limit(0);
+ }
+
+ /**
+ * Constructs a new InputStreamReader on the InputStream {@code in} and
+ * Charset {@code charset}.
+ *
+ * @param in
+ * the source InputStream from which to read characters.
+ * @param charset
+ * the Charset that defines the character converter
+ */
+ public InputStreamReader(InputStream in, Charset charset) {
+ super(in);
+ this.in = in;
+ decoder = charset.newDecoder().onMalformedInput(
+ CodingErrorAction.REPLACE).onUnmappableCharacter(
+ CodingErrorAction.REPLACE);
+ bytes.limit(0);
+ }
+
+ /**
+ * Closes this reader. This implementation closes the source InputStream
and
+ * releases all local storage.
+ *
+ * @throws IOException
+ * if an error occurs attempting to close this reader.
+ */
+ @Override
+ public void close() throws IOException {
+ synchronized (lock) {
+ decoder = null;
+ if (in != null) {
+ in.close();
+ in = null;
+ }
+ }
+ }
+
+ /**
+ * Returns the name of the encoding used to convert bytes into characters.
+ * The value {@code null} is returned if this reader has been closed.
+ *
+ * @return the name of the character converter or {@code null} if this
+ * reader is closed.
+ */
+ public String getEncoding() {
+ if (!isOpen()) {
+ return null;
+ }
+ return decoder.charset().name();
+ }
+
+ /**
+ * Reads a single character from this reader and returns it as an integer
+ * with the two higher-order bytes set to 0. Returns -1 if the end of the
+ * reader has been reached. The byte value is either obtained from
+ * converting bytes in this reader's buffer or by first filling the buffer
+ * from the source InputStream and then reading from the buffer.
+ *
+ * @return the character read or -1 if the end of the reader has been
+ * reached.
+ * @throws IOException
+ * if this reader is closed or some other I/O error occurs.
+ */
+ @Override
+ public int read() throws IOException {
+ synchronized (lock) {
+ if (!isOpen()) {
+ throw new IOException("InputStreamReader is closed.");
+ }
+
+ if (pending != (char) -1) {
+ char c = pending;
+ pending = (char) -1;
+ return c;
+ }
+ char buf[] = new char[2];
+ int nb = read(buf, 0, 2);
+ if (nb == 2) {
+ pending = buf[1];
+ }
+ if (nb > 0) {
+ return buf[0];
+ } else {
+ return -1;
+ }
+ }
+ }
+
+ /**
+ * Reads at most {@code length} characters from this reader and stores them
+ * at position {@code offset} in the character array {@code buf}. Returns
+ * the number of characters actually read or -1 if the end of the reader
has
+ * been reached. The bytes are either obtained from converting bytes in
this
+ * reader's buffer or by first filling the buffer from the source
+ * InputStream and then reading from the buffer.
+ *
+ * @param buf
+ * the array to store the characters read.
+ * @param offset
+ * the initial position in {@code buf} to store the characters
+ * read from this reader.
+ * @param length
+ * the maximum number of characters to read.
+ * @return the number of characters read or -1 if the end of the reader has
+ * been reached.
+ * @throws IndexOutOfBoundsException
+ * if {@code offset < 0} or {@code length < 0}, or if
+ * {@code offset + length} is greater than the length of
+ * {@code buf}.
+ * @throws IOException
+ * if this reader is closed or some other I/O error occurs.
+ */
+ @Override
+ public int read(char[] buf, int offset, int length) throws IOException {
+ synchronized (lock) {
+ if (!isOpen()) {
+ throw new IOException("InputStreamReader is closed.");
+ }
+ if (offset < 0 || offset > buf.length - length || length < 0) {
+ throw new IndexOutOfBoundsException();
+ }
+ if (length == 0) {
+ return 0;
+ }
+
+ CharBuffer out = CharBuffer.wrap(buf, offset, length);
+ CoderResult result = CoderResult.UNDERFLOW;
+
+ // bytes.remaining() indicates number of bytes in buffer
+ // when 1-st time entered, it'll be equal to zero
+ boolean needInput = !bytes.hasRemaining();
+
+ while (out.position() == offset) {
+ // fill the buffer if needed
+ if (needInput) {
+ try {
+ if ((in.available() == 0)
+ && (out.position() > offset)) {
+ // we could return the result without blocking read
+ break;
+ }
+ } catch (IOException e) {
+ // available didn't work so just try the read
+ }
+
+ int off = bytes.arrayOffset() + bytes.limit();
+ int was_red = in.read(bytes.array(), off, 1);
+
+ if (was_red == -1) {
+ endOfInput = true;
+ break;
+ } else if (was_red == 0) {
+ break;
+ }
+ bytes.limit(bytes.limit() + was_red);
+ }
+
+ // decode bytes
+ result = decoder.decode(bytes, out, false);
+
+ if (result.isUnderflow()) {
+ // compact the buffer if no space left
+ if (bytes.limit() == bytes.capacity()) {
+ bytes.compact();
+ bytes.limit(bytes.position());
+ bytes.position(0);
+ }
+ needInput = true;
+ } else {
+ break;
+ }
+ }
+
+ if (result == CoderResult.UNDERFLOW && endOfInput) {
+ result = decoder.decode(bytes, out, true);
+ decoder.flush(out);
+ decoder.reset();
+ }
+ if (result.isMalformed()) {
+ throw new MalformedInputException(result.length());
+ } else if (result.isUnmappable()) {
+ throw new UnmappableCharacterException(result.length());
+ }
+
+ return out.position() - offset == 0 ? -1 : out.position() - offset;
+ }
+ }
+
+ /*
+ * Answer a boolean indicating whether or not this InputStreamReader is
+ * open.
+ */
+ private boolean isOpen() {
+ return in != null;
+ }
+
+ /**
+ * Indicates whether this reader is ready to be read without blocking. If
+ * the result is {@code true}, the next {@code read()} will not block. If
+ * the result is {@code false} then this reader may or may not block when
+ * {@code read()} is called. This implementation returns {@code true} if
+ * there are bytes available in the buffer or the source stream has bytes
+ * available.
+ *
+ * @return {@code true} if the receiver will not block when {@code read()}
+ * is called, {@code false} if unknown or blocking will occur.
+ * @throws IOException
+ * if this reader is closed or some other I/O error occurs.
+ */
+ @Override
+ public boolean ready() throws IOException {
+ synchronized (lock) {
+ if (in == null) {
+ throw new IOException("InputStreamReader is closed.");
+ }
+ try {
+ return bytes.hasRemaining() || in.available() > 0;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git
a/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java
b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java
index 0c74489..8dafe9e 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java
@@ -18,6 +18,7 @@
*/
package org.apache.sshd.server.scp;
+import java.io.IOException;
import java.util.Collection;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Supplier;
@@ -31,9 +32,11 @@ import org.apache.sshd.common.util.GenericUtils;
import org.apache.sshd.common.util.ObjectBuilder;
import org.apache.sshd.common.util.threads.CloseableExecutorService;
import org.apache.sshd.common.util.threads.ManagedExecutorServiceSupplier;
+import org.apache.sshd.server.channel.ChannelSession;
import org.apache.sshd.server.command.AbstractDelegatingCommandFactory;
import org.apache.sshd.server.command.Command;
import org.apache.sshd.server.command.CommandFactory;
+import org.apache.sshd.server.shell.ShellFactory;
/**
* This <code>CommandFactory</code> can be used as a standalone command
factory or can be used to augment another
@@ -41,10 +44,11 @@ import org.apache.sshd.server.command.CommandFactory;
*
* @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a>
* @see ScpCommand
+ * @see ScpShell
*/
public class ScpCommandFactory
extends AbstractDelegatingCommandFactory
- implements ManagedExecutorServiceSupplier, ScpFileOpenerHolder,
Cloneable {
+ implements ManagedExecutorServiceSupplier, ScpFileOpenerHolder,
Cloneable, ShellFactory {
public static final String SCP_FACTORY_NAME = "scp";
@@ -102,8 +106,8 @@ public class ScpCommandFactory
private Supplier<? extends CloseableExecutorService> executorsProvider;
private ScpFileOpener fileOpener;
- private int sendBufferSize = ScpHelper.MIN_SEND_BUFFER_SIZE;
- private int receiveBufferSize = ScpHelper.MIN_RECEIVE_BUFFER_SIZE;
+ private int sendBufferSize = ScpHelper.DEFAULT_SEND_BUFFER_SIZE;
+ private int receiveBufferSize = ScpHelper.DEFAULT_RECEIVE_BUFFER_SIZE;
private Collection<ScpTransferEventListener> listeners = new
CopyOnWriteArraySet<>();
private ScpTransferEventListener listenerProxy;
@@ -215,6 +219,15 @@ public class ScpCommandFactory
getScpFileOpener(), listenerProxy);
}
+ @Override
+ public Command createShell(ChannelSession channel) throws IOException {
+ return new ScpShell(
+ channel,
+ resolveExecutorService(),
+ getSendBufferSize(), getReceiveBufferSize(),
+ getScpFileOpener(), listenerProxy);
+ }
+
protected CloseableExecutorService resolveExecutorService(String command) {
return resolveExecutorService();
}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpShell.java
b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpShell.java
new file mode 100644
index 0000000..adbebb1
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpShell.java
@@ -0,0 +1,588 @@
+/*
+ * 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.sshd.server.scp;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOError;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.sshd.common.scp.ScpException;
+import org.apache.sshd.common.scp.ScpFileOpener;
+import org.apache.sshd.common.scp.ScpHelper;
+import org.apache.sshd.common.scp.ScpTransferEventListener;
+import org.apache.sshd.common.scp.helpers.DefaultScpFileOpener;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.threads.CloseableExecutorService;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.command.AbstractFileSystemCommand;
+
+/**
+ * This commands SCP support for a ChannelSession.
+ *
+ * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a>
+ */
+public class ScpShell extends AbstractFileSystemCommand {
+
+ public static final String STATUS = "status";
+
+ protected static final boolean IS_WINDOWS =
System.getProperty("os.name").toLowerCase().contains("win");
+
+ protected static final List<String> WINDOWS_EXECUTABLE_EXTENSIONS =
Collections.unmodifiableList(Arrays.asList(".bat", ".exe", ".cmd"));
+ protected static final LinkOption[] EMPTY_LINK_OPTIONS = new LinkOption[0];
+
+
+ protected final ChannelSession channel;
+ protected ScpFileOpener opener;
+ protected ScpTransferEventListener listener;
+ protected int sendBufferSize;
+ protected int receiveBufferSize;
+ protected Path currentDir;
+ protected Map<String, Object> variables = new HashMap<>();
+
+ public ScpShell(ChannelSession channel, CloseableExecutorService
executorService,
+ int sendSize, int receiveSize,
+ ScpFileOpener fileOpener, ScpTransferEventListener
eventListener) {
+ super(null, executorService);
+ this.channel = channel;
+
+ if (sendSize < ScpHelper.MIN_SEND_BUFFER_SIZE) {
+ throw new IllegalArgumentException(
+ "<ScpShell> send buffer size "
+ + "(" + sendSize + ") below minimum required "
+ + "(" + ScpHelper.MIN_SEND_BUFFER_SIZE + ")");
+ }
+ sendBufferSize = sendSize;
+
+ if (receiveSize < ScpHelper.MIN_RECEIVE_BUFFER_SIZE) {
+ throw new IllegalArgumentException(
+ "<ScpCommmand> receive buffer size "
+ + "(" + sendSize + ") below minimum required "
+ + "(" + ScpHelper.MIN_RECEIVE_BUFFER_SIZE + ")");
+ }
+ receiveBufferSize = receiveSize;
+
+ opener = (fileOpener == null) ? DefaultScpFileOpener.INSTANCE :
fileOpener;
+ listener = (eventListener == null) ? ScpTransferEventListener.EMPTY :
eventListener;
+
+ }
+
+ protected void println(Object x, OutputStream out) {
+ try {
+ String s = x + System.lineSeparator();
+ out.write(s.getBytes());
+ } catch (IOException e) {
+ throw new IOError(e);
+ }
+ }
+
+ @Override
+ public void run() {
+ String command = null;
+ try {
+ currentDir = opener.resolveLocalPath(channel.getSession(),
fileSystem, ".");
+ // Use a special stream reader so that the stream can be used with
the scp command
+ try (Reader r = new InputStreamReader(getInputStream(),
StandardCharsets.UTF_8)) {
+ for (;;) {
+ command = readLine(r);
+ if (command.length() == 0 || !handleCommandLine(command)) {
+ return;
+ }
+ }
+ }
+ } catch (InterruptedIOException e) {
+ // Ignore - signaled end
+ } catch (Exception e) {
+ String message = "Failed (" + e.getClass().getSimpleName() + ") to
handle '" + command + "': " + e.getMessage();
+ try {
+ OutputStream stderr = getErrorStream();
+ stderr.write(message.getBytes(StandardCharsets.US_ASCII));
+ } catch (IOException ioe) {
+ log.warn("Failed ({}) to write error message={}: {}",
+ e.getClass().getSimpleName(), message,
ioe.getMessage());
+ } finally {
+ onExit(-1, message);
+ }
+ } finally {
+ onExit(0);
+ }
+ }
+
+ protected String readLine(Reader reader) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ int c;
+ while ((c = reader.read()) >= 0) {
+ if (c == '\n') {
+ break;
+ }
+ sb.append((char) c);
+ }
+ return sb.toString();
+ }
+
+ protected boolean handleCommandLine(String command) throws Exception {
+ List<String[]> cmds = parse(command);
+ for (String[] argv : cmds) {
+ switch (argv[0]) {
+ case "echo":
+ echo(argv);
+ break;
+ case "pwd":
+ pwd(argv);
+ break;
+ case "cd":
+ cd(argv);
+ break;
+ case "ls":
+ ls(argv);
+ break;
+ case "scp":
+ scp(argv);
+ break;
+ case "groups":
+ variables.put(STATUS, 0);
+ break;
+ case "unset":
+ case "unalias":
+ case "printenv":
+ variables.put(STATUS, 1);
+ break;
+ default:
+ variables.put(STATUS, 127);
+ getErrorStream().write(("command not found: " + argv[0] +
"\n").getBytes());
+ }
+ getOutputStream().flush();
+ getErrorStream().flush();
+ }
+ return true;
+ }
+
+ protected List<String[]> parse(String command) {
+ List<String[]> cmds = new ArrayList<>();
+ List<String> args = new ArrayList<>();
+ StringBuilder arg = new StringBuilder();
+ char quote = 0;
+ boolean escaped = false;
+ for (int i = 0; i < command.length(); i++) {
+ char ch = command.charAt(i);
+ if (escaped) {
+ arg.append(ch);
+ escaped = false;
+ } else if (ch == quote) {
+ quote = 0;
+ } else if (ch == '"' || ch == '\'') {
+ quote = ch;
+ } else if (ch == '\\') {
+ escaped = true;
+ } else if (quote == 0 && Character.isWhitespace(ch)) {
+ if (arg.length() > 0) {
+ args.add(arg.toString());
+ arg.setLength(0);
+ }
+ } else if (quote == 0 && ch == ';') {
+ if (arg.length() > 0) {
+ args.add(arg.toString());
+ arg.setLength(0);
+ }
+ if (!args.isEmpty()) {
+ cmds.add(args.toArray(new String[0]));
+ }
+ args.clear();
+ } else {
+ arg.append(ch);
+ }
+ }
+ if (arg.length() > 0) {
+ args.add(arg.toString());
+ arg.setLength(0);
+ }
+ if (!args.isEmpty()) {
+ cmds.add(args.toArray(new String[0]));
+ }
+ return cmds;
+ }
+
+ protected void scp(String[] argv) throws Exception {
+ boolean optR = false;
+ boolean optT = false;
+ boolean optF = false;
+ boolean optD = false;
+ boolean optP = false;
+ boolean isOption = true;
+ String path = null;
+ for (int i = 1; i < argv.length; i++) {
+ if (isOption && argv[i].startsWith("-")) {
+ switch (argv[i]) {
+ case "-r": optR = true; break;
+ case "-t": optT = true; break;
+ case "-f": optF = true; break;
+ case "-d": optD = true; break;
+ case "-p": optP = true; break;
+ default:
+ println("scp: unsupported option: " + argv[i],
getErrorStream());
+ variables.put(STATUS, 1);
+ return;
+ }
+ } else if (path == null) {
+ path = argv[i];
+ isOption = false;
+ } else {
+ println("scp: one and only one argument expected",
getErrorStream());
+ variables.put(STATUS, 1);
+ return;
+ }
+ }
+ if (optT && optF || !optT && !optF) {
+ println("scp: one and only one of -t and -f option expected",
getErrorStream());
+ variables.put(STATUS, 1);
+ } else {
+ try {
+ ScpHelper helper = new ScpHelper(channel.getSession(),
getInputStream(), getOutputStream(),
+ fileSystem, opener, listener);
+ if (optT) {
+ helper.receive(helper.resolveLocalPath(path), optR, optD,
optP, receiveBufferSize);
+ } else {
+ helper.send(Collections.singletonList(path), optR, optP,
sendBufferSize);
+ }
+ variables.put(STATUS, 0);
+ } catch (IOException e) {
+ Integer statusCode = e instanceof ScpException ?
((ScpException) e).getExitStatus() : null;
+ int exitValue = (statusCode == null) ? ScpHelper.ERROR :
statusCode;
+ // this is an exception so status cannot be OK/WARNING
+ if ((exitValue == ScpHelper.OK) || (exitValue ==
ScpHelper.WARNING)) {
+ exitValue = ScpHelper.ERROR;
+ }
+ String exitMessage = GenericUtils.trimToEmpty(e.getMessage());
+ ScpHelper.sendResponseMessage(getOutputStream(), exitValue,
exitMessage);
+ variables.put(STATUS, exitValue);
+ }
+ }
+ }
+
+
+ protected void echo(String[] argv) throws Exception {
+ StringBuilder buf = new StringBuilder();
+ for (int k = 1; k < argv.length; k++) {
+ String arg = argv[k];
+ if (buf.length() > 0) {
+ buf.append(' ');
+ }
+ int vstart = -1;
+ for (int i = 0; i < arg.length(); i++) {
+ int c = arg.charAt(i);
+ if (vstart >= 0) {
+ if (c != '_' && (c < '0' || c > '9') && (c < 'A' || c >
'Z') && (c < 'a' || c > 'z')) {
+ if (vstart == i) {
+ buf.append('$');
+ } else {
+ String n = arg.substring(vstart, i);
+ Object v = variables.get(n);
+ if (v != null) {
+ buf.append(v);
+ }
+ }
+ vstart = -1;
+ }
+ } else if (c == '$') {
+ vstart = i + 1;
+ } else {
+ buf.append((char) c);
+ }
+ }
+ if (vstart >= 0) {
+ String n = arg.substring(vstart);
+ if (n.isEmpty()) {
+ buf.append('$');
+ } else {
+ Object v = variables.get(n);
+ if (v != null) {
+ buf.append(v);
+ }
+ }
+ }
+ }
+ println(buf, getOutputStream());
+ variables.put(STATUS, 0);
+ }
+
+ protected void pwd(String[] argv) throws Exception {
+ if (argv.length != 1) {
+ println("pwd: too many arguments", getErrorStream());
+ variables.put(STATUS, 1);
+ } else {
+ println(currentDir, getOutputStream());
+ variables.put(STATUS, 0);
+ }
+ }
+
+ protected void cd(String[] argv) throws Exception {
+ if (argv.length != 2) {
+ println("cd: too many or too few arguments", getErrorStream());
+ variables.put(STATUS, 1);
+ } else {
+ Path cwd = currentDir;
+ String path = argv[1];
+ cwd = cwd.resolve(path).toAbsolutePath().normalize();
+ if (!Files.exists(cwd)) {
+ println("no such file or directory: " + path,
getErrorStream());
+ variables.put(STATUS, 1);
+ } else if (!Files.isDirectory(cwd)) {
+ println("not a directory: " + path, getErrorStream());
+ variables.put(STATUS, 1);
+ } else {
+ currentDir = cwd;
+ variables.put(STATUS, 0);
+ }
+ }
+ }
+
+ protected void ls(String[] argv) throws Exception {
+ // find options
+ boolean a = false, l = false, f = false;
+ for (int k = 1; k < argv.length; k++) {
+ if (argv[k].equals("--full-time")) {
+ f = true;
+ } else if (argv[k].startsWith("-")) {
+ for (int i = 1; i < argv[k].length(); i++) {
+ switch (argv[k].charAt(i)) {
+ case 'a': a = true; break;
+ case 'l': l = true; break;
+ default:
+ println("unsupported option: -" +
argv[k].charAt(i), getErrorStream());
+ variables.put(STATUS, 1);
+ return;
+ }
+ }
+ } else {
+ println("unsupported option: " + argv[k], getErrorStream());
+ variables.put(STATUS, 1);
+ return;
+ }
+ }
+ boolean optListAll = a;
+ boolean optLong = l;
+ boolean optFullTime = f;
+ // list current directory content
+ Predicate<Path> filter = p -> optListAll ||
p.getFileName().toString().equals(".")
+ || p.getFileName().toString().equals("..") ||
!p.getFileName().toString().startsWith(".");
+ String[] synth = currentDir.toString().equals("/") ? new String[] {
"." } : new String[] { ".", ".." };
+ Stream.concat(Stream.of(synth).map(currentDir::resolve),
Files.list(currentDir))
+ .filter(filter)
+ .map(p -> new PathEntry(p, currentDir))
+ .sorted()
+ .map(p -> p.display(optLong, optFullTime))
+ .forEach(str -> println(str, getOutputStream()));
+ variables.put(STATUS, 0);
+ }
+
+ protected static class PathEntry implements Comparable<PathEntry> {
+
+ protected final Path abs;
+ protected final Path path;
+ protected final Map<String, Object> attributes;
+
+ public PathEntry(Path abs, Path root) {
+ this.abs = abs;
+ this.path = abs.startsWith(root) ? root.relativize(abs) : abs;
+ this.attributes = readAttributes(abs);
+ }
+
+ @Override
+ public int compareTo(PathEntry o) {
+ return path.toString().compareTo(o.path.toString());
+ }
+
+ public String display(boolean optLongDisplay, boolean optFullTime) {
+ if (optLongDisplay) {
+ String username;
+ if (attributes.containsKey("owner")) {
+ username = Objects.toString(attributes.get("owner"), null);
+ } else {
+ username = "owner";
+ }
+ if (username.length() > 8) {
+ username = username.substring(0, 8);
+ } else {
+ for (int i = username.length(); i < 8; i++) {
+ username += " ";
+ }
+ }
+ String group;
+ if (attributes.containsKey("group")) {
+ group = Objects.toString(attributes.get("group"), null);
+ } else {
+ group = "group";
+ }
+ if (group.length() > 8) {
+ group = group.substring(0, 8);
+ } else {
+ for (int i = group.length(); i < 8; i++) {
+ group += " ";
+ }
+ }
+ Number length = (Number) attributes.get("size");
+ if (length == null) {
+ length = 0L;
+ }
+ String lengthString = String.format("%1$8s", length);
+ @SuppressWarnings("unchecked")
+ Set<PosixFilePermission> perms = (Set<PosixFilePermission>)
attributes.get("permissions");
+ if (perms == null) {
+ perms = EnumSet.noneOf(PosixFilePermission.class);
+ }
+ // TODO: all fields should be padded to align
+ return (is("isDirectory") ? "d" : (is("isSymbolicLink") ? "l"
: (is("isOther") ? "o" : "-")))
+ + PosixFilePermissions.toString(perms) + " "
+ + String.format("%3s",
(attributes.containsKey("nlink") ? attributes.get("nlink").toString() : "1"))
+ + " " + username + " " + group + " " + lengthString +
" "
+ + toString((FileTime)
attributes.get("lastModifiedTime"), optFullTime)
+ + " " + shortDisplay();
+ } else {
+ return shortDisplay();
+ }
+ }
+
+ protected boolean is(String attr) {
+ Object d = attributes.get(attr);
+ return d instanceof Boolean && (Boolean) d;
+ }
+
+ protected String shortDisplay() {
+ if (is("isSymbolicLink")) {
+ try {
+ Path l = Files.readSymbolicLink(abs);
+ return path.toString() + " -> " + l.toString();
+ } catch (IOException e) {
+ // ignore
+ }
+ }
+ return path.toString();
+ }
+
+ protected String toString(FileTime time, boolean optFullTime) {
+ long millis = (time != null) ? time.toMillis() : -1L;
+ if (millis < 0L) {
+ return "------------";
+ }
+ ZonedDateTime dt =
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault());
+ if (optFullTime) {
+ return DateTimeFormatter.ofPattern("MMM ppd HH:mm:ss
yyyy").format(dt);
+ }
+ // Less than six months
+ else if (System.currentTimeMillis() - millis < 183L * 24L * 60L *
60L * 1000L) {
+ return DateTimeFormatter.ofPattern("MMM ppd HH:mm").format(dt);
+ }
+ // Older than six months
+ else {
+ return DateTimeFormatter.ofPattern("MMM ppd yyyy").format(dt);
+ }
+ }
+
+ protected static Map<String, Object> readAttributes(Path path) {
+ Map<String, Object> attrs = new
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ for (String view :
path.getFileSystem().supportedFileAttributeViews()) {
+ try {
+ Map<String, Object> ta = Files.readAttributes(path, view +
":*", EMPTY_LINK_OPTIONS);
+ ta.forEach(attrs::putIfAbsent);
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+ attrs.computeIfAbsent("isExecutable", s ->
Files.isExecutable(path));
+ attrs.computeIfAbsent("permissions", s ->
getPermissionsFromFile(path.toFile()));
+ return attrs;
+ }
+ }
+
+ /**
+ * @param f The {@link File} to be checked
+ * @return A {@link Set} of {@link PosixFilePermission}s based on whether
+ * the file is readable/writable/executable. If so, then <U>all</U> the
+ * relevant permissions are set (i.e., owner, group and others)
+ */
+ protected static Set<PosixFilePermission> getPermissionsFromFile(File f) {
+ Set<PosixFilePermission> perms =
EnumSet.noneOf(PosixFilePermission.class);
+ if (f.canRead()) {
+ perms.add(PosixFilePermission.OWNER_READ);
+ perms.add(PosixFilePermission.GROUP_READ);
+ perms.add(PosixFilePermission.OTHERS_READ);
+ }
+
+ if (f.canWrite()) {
+ perms.add(PosixFilePermission.OWNER_WRITE);
+ perms.add(PosixFilePermission.GROUP_WRITE);
+ perms.add(PosixFilePermission.OTHERS_WRITE);
+ }
+
+ if (f.canExecute() || (IS_WINDOWS &&
isWindowsExecutable(f.getName()))) {
+ perms.add(PosixFilePermission.OWNER_EXECUTE);
+ perms.add(PosixFilePermission.GROUP_EXECUTE);
+ perms.add(PosixFilePermission.OTHERS_EXECUTE);
+ }
+
+ return perms;
+ }
+
+ /**
+ * @param fileName The file name to be evaluated - ignored if {@code
null}/empty
+ * @return {@code true} if the file ends in one of the {@link
#WINDOWS_EXECUTABLE_EXTENSIONS}
+ */
+ protected static boolean isWindowsExecutable(String fileName) {
+ if ((fileName == null) || (fileName.length() <= 0)) {
+ return false;
+ }
+ for (String suffix : WINDOWS_EXECUTABLE_EXTENSIONS) {
+ if (fileName.endsWith(suffix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+}
diff --git a/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java
b/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java
index c32f7e7..c9ef06b 100644
--- a/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java
+++ b/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java
@@ -149,7 +149,9 @@ public class ScpTest extends BaseTestSupport {
public static void setupClientAndServer() throws Exception {
JSchLogger.init();
sshd = CoreTestSupportUtils.setupTestServer(ScpTest.class);
- sshd.setCommandFactory(new ScpCommandFactory());
+ ScpCommandFactory factory = new ScpCommandFactory();
+ sshd.setCommandFactory(factory);
+ sshd.setShellFactory(factory);
sshd.start();
port = sshd.getPort();