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
commit 449c2a56cbfef7c1f7abbb441bcce4ab3116dc65 Author: Guillaume Nodet <gno...@gmail.com> AuthorDate: Wed Jun 3 21:58:57 2020 +0200 [SSHD-1009] SSHD SCP does not work with WinSCP --- .../apache/sshd/server/scp/InputStreamReader.java | 329 ++++++++++++ .../apache/sshd/server/scp/ScpCommandFactory.java | 19 +- .../java/org/apache/sshd/server/scp/ScpShell.java | 597 +++++++++++++++++++++ .../java/org/apache/sshd/client/scp/ScpTest.java | 4 +- .../sshd/server/scp/ScpCommandFactoryTest.java | 4 +- 5 files changed, 947 insertions(+), 6 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..0f742a6 --- /dev/null +++ b/sshd-scp/src/main/java/org/apache/sshd/server/scp/InputStreamReader.java @@ -0,0 +1,329 @@ +/* + * 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. + */ +/* + * 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 static final int BUFFER_SIZE = 4; + + CharsetDecoder decoder; + + ByteBuffer bytes = ByteBuffer.allocate(BUFFER_SIZE); + + char pending = (char) -1; + + private InputStream in; + + private boolean endOfInput; + + /** + * 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; + } + } + } +} 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:d...@mina.apache.org">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..ac4af52 --- /dev/null +++ b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpShell.java @@ -0,0 +1,597 @@ +/* + * 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.File; +import java.io.IOError; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.OutputStream; +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.Predicate; +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:d...@mina.apache.org">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(); + while (true) { + int c = reader.read(); + if (c < 0 || 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; + boolean l = false; + boolean 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); + } else if (System.currentTimeMillis() - millis < 183L * 24L * 60L * 60L * 1000L) { + return DateTimeFormatter.ofPattern("MMM ppd HH:mm").format(dt); + } 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(); diff --git a/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java b/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java index 1f0b557..ae378c9 100644 --- a/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java +++ b/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java @@ -50,8 +50,8 @@ public class ScpCommandFactoryTest extends BaseTestSupport { ScpCommandFactory factory = new ScpCommandFactory.Builder().build(); assertNull("Mismatched delegate", factory.getDelegateCommandFactory()); assertNull("Mismatched executor", factory.getExecutorServiceProvider()); - assertEquals("Mismatched send size", ScpHelper.MIN_SEND_BUFFER_SIZE, factory.getSendBufferSize()); - assertEquals("Mismatched receive size", ScpHelper.MIN_RECEIVE_BUFFER_SIZE, factory.getReceiveBufferSize()); + assertEquals("Mismatched send size", ScpHelper.DEFAULT_SEND_BUFFER_SIZE, factory.getSendBufferSize()); + assertEquals("Mismatched receive size", ScpHelper.DEFAULT_RECEIVE_BUFFER_SIZE, factory.getReceiveBufferSize()); } /**