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 f8649545fd5a1e3e4a0e4efcc1d0aa6fcc4acc52 Author: Lyor Goldstein <lgoldst...@apache.org> AuthorDate: Fri Jun 5 11:54:03 2020 +0300 [SSHD-1009] Fixed bugs in ScpShell to make it work on Windows --- .../apache/sshd/common/file/FileSystemAware.java | 18 + .../apache/sshd/common/file/FileSystemFactory.java | 15 +- .../file/nativefs/NativeFileSystemFactory.java | 30 +- .../file/virtualfs/VirtualFileSystemFactory.java | 24 +- .../apache/sshd/server/channel/ChannelSession.java | 2 +- .../java/org/apache/sshd/server/scp/ScpShell.java | 614 ++++++++++++++------- .../sshd/client/subsystem/sftp/SftpTest.java | 14 +- 7 files changed, 491 insertions(+), 226 deletions(-) diff --git a/sshd-common/src/main/java/org/apache/sshd/common/file/FileSystemAware.java b/sshd-common/src/main/java/org/apache/sshd/common/file/FileSystemAware.java index 5393cde..65817af 100644 --- a/sshd-common/src/main/java/org/apache/sshd/common/file/FileSystemAware.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/file/FileSystemAware.java @@ -18,8 +18,11 @@ */ package org.apache.sshd.common.file; +import java.io.IOException; import java.nio.file.FileSystem; +import org.apache.sshd.common.session.SessionContext; + /** * Interface that can be implemented by a command to be able to access the file system in which this command will be * used. @@ -27,6 +30,21 @@ import java.nio.file.FileSystem; @FunctionalInterface public interface FileSystemAware { /** + * Sets the {@link FileSystemFactory} used to create the {@link FileSystem} to be used by the session + * + * @param factory The factory instance + * @param session The {@link SessionContext} + * @throws IOException If failed to resolve/create the file system + * @see #setFileSystem(FileSystem) + */ + default void setFileSystemFactory( + FileSystemFactory factory, SessionContext session) + throws IOException { + FileSystem fs = factory.createFileSystem(session); + setFileSystem(fs); + } + + /** * Set the file system in which this shell will be executed. * * @param fileSystem the file system diff --git a/sshd-core/src/main/java/org/apache/sshd/common/file/FileSystemFactory.java b/sshd-common/src/main/java/org/apache/sshd/common/file/FileSystemFactory.java similarity index 71% rename from sshd-core/src/main/java/org/apache/sshd/common/file/FileSystemFactory.java rename to sshd-common/src/main/java/org/apache/sshd/common/file/FileSystemFactory.java index d95f405..37cb2b3 100644 --- a/sshd-core/src/main/java/org/apache/sshd/common/file/FileSystemFactory.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/file/FileSystemFactory.java @@ -21,23 +21,30 @@ package org.apache.sshd.common.file; import java.io.IOException; import java.nio.file.FileSystem; +import java.nio.file.Path; -import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.session.SessionContext; /** * Factory for file system implementations - it returns the file system for user. * * @author <a href="http://mina.apache.org">Apache MINA Project</a> */ -@FunctionalInterface public interface FileSystemFactory { + /** + * + * @param session The session created for the user + * @return The recommended user home directory - {@code null} if none + * @throws IOException If failed to resolve user's home directory + */ + Path getUserHomeDir(SessionContext session) throws IOException; /** * Create user specific file system. * * @param session The session created for the user * @return The current {@link FileSystem} for the provided session - * @throws IOException if the filesystem can not be created + * @throws IOException if the file system can not be created */ - FileSystem createFileSystem(Session session) throws IOException; + FileSystem createFileSystem(SessionContext session) throws IOException; } diff --git a/sshd-core/src/main/java/org/apache/sshd/common/file/nativefs/NativeFileSystemFactory.java b/sshd-common/src/main/java/org/apache/sshd/common/file/nativefs/NativeFileSystemFactory.java similarity index 78% rename from sshd-core/src/main/java/org/apache/sshd/common/file/nativefs/NativeFileSystemFactory.java rename to sshd-common/src/main/java/org/apache/sshd/common/file/nativefs/NativeFileSystemFactory.java index 2343851..784fc94 100644 --- a/sshd-core/src/main/java/org/apache/sshd/common/file/nativefs/NativeFileSystemFactory.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/file/nativefs/NativeFileSystemFactory.java @@ -23,12 +23,15 @@ import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.FileSystems; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.NotDirectoryException; import java.nio.file.Path; import java.nio.file.Paths; import org.apache.sshd.common.file.FileSystemFactory; -import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.session.SessionContext; +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.common.util.OsUtils; import org.apache.sshd.common.util.ValidateUtils; import org.apache.sshd.common.util.logging.AbstractLoggingBean; @@ -38,7 +41,7 @@ import org.apache.sshd.common.util.logging.AbstractLoggingBean; * @author <a href="http://mina.apache.org">Apache MINA Project</a> */ public class NativeFileSystemFactory extends AbstractLoggingBean implements FileSystemFactory { - public static final String DEFAULT_USERS_HOME = "/home"; + public static final String DEFAULT_USERS_HOME = OsUtils.isWin32() ? "C:\\Users" : "/home"; public static final NativeFileSystemFactory INSTANCE = new NativeFileSystemFactory(); @@ -90,12 +93,29 @@ public class NativeFileSystemFactory extends AbstractLoggingBean implements File } @Override - public FileSystem createFileSystem(Session session) throws IOException { + public Path getUserHomeDir(SessionContext session) throws IOException { String userName = session.getUsername(); + if (GenericUtils.isEmpty(userName)) { + return null; + } + + String homeRoot = getUsersHomeDir(); + if (GenericUtils.isEmpty(homeRoot)) { + return null; + } + + return Paths.get(homeRoot, userName).normalize().toAbsolutePath(); + } + + @Override + public FileSystem createFileSystem(SessionContext session) throws IOException { // create home if does not exist if (isCreateHome()) { - String homeRoot = getUsersHomeDir(); - Path homeDir = Paths.get(homeRoot, userName).normalize().toAbsolutePath(); + Path homeDir = getUserHomeDir(session); + if (homeDir == null) { + throw new InvalidPathException(session.getUsername(), "Cannot resolve home directory"); + } + if (Files.exists(homeDir)) { if (!Files.isDirectory(homeDir)) { throw new NotDirectoryException(homeDir.toString()); diff --git a/sshd-core/src/main/java/org/apache/sshd/common/file/virtualfs/VirtualFileSystemFactory.java b/sshd-common/src/main/java/org/apache/sshd/common/file/virtualfs/VirtualFileSystemFactory.java similarity index 87% rename from sshd-core/src/main/java/org/apache/sshd/common/file/virtualfs/VirtualFileSystemFactory.java rename to sshd-common/src/main/java/org/apache/sshd/common/file/virtualfs/VirtualFileSystemFactory.java index 5dd87bf..7ad53e5 100644 --- a/sshd-core/src/main/java/org/apache/sshd/common/file/virtualfs/VirtualFileSystemFactory.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/file/virtualfs/VirtualFileSystemFactory.java @@ -29,7 +29,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.apache.sshd.common.file.FileSystemFactory; import org.apache.sshd.common.file.root.RootedFileSystemProvider; -import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.session.SessionContext; import org.apache.sshd.common.util.ValidateUtils; /** @@ -66,17 +66,7 @@ public class VirtualFileSystemFactory implements FileSystemFactory { } @Override - public FileSystem createFileSystem(Session session) throws IOException { - String username = session.getUsername(); - Path dir = computeRootDir(session); - if (dir == null) { - throw new InvalidPathException(username, "Cannot resolve home directory"); - } - - return new RootedFileSystemProvider().newFileSystem(dir, Collections.emptyMap()); - } - - protected Path computeRootDir(Session session) throws IOException { + public Path getUserHomeDir(SessionContext session) throws IOException { String username = session.getUsername(); Path homeDir = getUserHomeDir(username); if (homeDir == null) { @@ -85,4 +75,14 @@ public class VirtualFileSystemFactory implements FileSystemFactory { return homeDir; } + + @Override + public FileSystem createFileSystem(SessionContext session) throws IOException { + Path dir = getUserHomeDir(session); + if (dir == null) { + throw new InvalidPathException(session.getUsername(), "Cannot resolve home directory"); + } + + return new RootedFileSystemProvider().newFileSystem(dir, Collections.emptyMap()); + } } diff --git a/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java b/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java index 3303584..132c5d9 100644 --- a/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java +++ b/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java @@ -760,7 +760,7 @@ public class ChannelSession extends AbstractServerChannel { if (command instanceof FileSystemAware) { ServerFactoryManager manager = ((ServerSession) session).getFactoryManager(); FileSystemFactory factory = manager.getFileSystemFactory(); - ((FileSystemAware) command).setFileSystem(factory.createFileSystem(session)); + ((FileSystemAware) command).setFileSystemFactory(factory, session); } // If the shell wants to use non-blocking io if (command instanceof AsyncCommand) { 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 index ac4af52..c215986 100644 --- 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 @@ -18,15 +18,15 @@ */ 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.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; 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; @@ -36,11 +36,12 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -48,13 +49,18 @@ import java.util.TreeMap; import java.util.function.Predicate; import java.util.stream.Stream; +import org.apache.sshd.common.PropertyResolverUtils; +import org.apache.sshd.common.file.FileSystemFactory; 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.session.SessionContext; import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.common.util.io.IoUtils; import org.apache.sshd.common.util.threads.CloseableExecutorService; +import org.apache.sshd.server.Environment; import org.apache.sshd.server.channel.ChannelSession; import org.apache.sshd.server.command.AbstractFileSystemCommand; @@ -64,22 +70,42 @@ import org.apache.sshd.server.command.AbstractFileSystemCommand; * @author <a href="mailto:d...@mina.apache.org">Apache MINA SSHD Project</a> */ public class ScpShell extends AbstractFileSystemCommand { + /** + * Used to indicate the {@link Charset} (or its name) for encoding referenced files/folders names - extracted from + * the client channel session when 1st initialized. + * + * @see #DEFAULT_NAME_ENCODING_CHARSET + */ + public static final String NAME_ENCODING_CHARSET = "scp-shell-name-encoding-charset"; + + /** + * Default value of {@value #NAME_ENCODING_CHARSET} + */ + public static final Charset DEFAULT_NAME_ENCODING_CHARSET = StandardCharsets.UTF_8; public static final String STATUS = "status"; - protected static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("win"); + /** The "PWD" environment variable */ + public static final String ENV_PWD = "PWD"; - 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]; + /** The "HOME" environment variable */ + public static final String ENV_HOME = "HOME"; + + /** + * Key for the language - format "en_US.UTF-8" + */ + public static final String ENV_LANG = "LANG"; protected final ChannelSession channel; - protected ScpFileOpener opener; - protected ScpTransferEventListener listener; - protected int sendBufferSize; - protected int receiveBufferSize; + protected final Map<String, Object> variables = new HashMap<>(); + protected final Charset nameEncodingCharset; + + protected final ScpFileOpener opener; + protected final ScpTransferEventListener listener; + protected final int sendBufferSize; + protected final int receiveBufferSize; protected Path currentDir; - protected Map<String, Object> variables = new HashMap<>(); + protected Path homeDir; public ScpShell(ChannelSession channel, CloseableExecutorService executorService, int sendSize, int receiveSize, @@ -87,6 +113,9 @@ public class ScpShell extends AbstractFileSystemCommand { super(null, executorService); this.channel = channel; + nameEncodingCharset = PropertyResolverUtils.getCharset( + channel, NAME_ENCODING_CHARSET, DEFAULT_NAME_ENCODING_CHARSET); + if (sendSize < ScpHelper.MIN_SEND_BUFFER_SIZE) { throw new IllegalArgumentException( "<ScpShell> send buffer size " @@ -105,42 +134,94 @@ public class ScpShell extends AbstractFileSystemCommand { opener = (fileOpener == null) ? DefaultScpFileOpener.INSTANCE : fileOpener; listener = (eventListener == null) ? ScpTransferEventListener.EMPTY : eventListener; + } + @Override + public void setFileSystemFactory(FileSystemFactory factory, SessionContext session) throws IOException { + homeDir = factory.getUserHomeDir(session); + super.setFileSystemFactory(factory, session); } - protected void println(Object x, OutputStream out) { + protected void println( + String cmd, Object x, OutputStream out, Charset cs) { try { - String s = x + System.lineSeparator(); - out.write(s.getBytes()); + String s = x.toString(); + if (log.isDebugEnabled()) { + log.debug("println({})[{}]: {}", + channel, cmd, s.replace('\n', ' ').replace('\t', ' ')); + } + out.write(s.getBytes(cs)); + // always write LF even if running on Windows + out.write('\n'); } catch (IOException e) { throw new IOError(e); } } + protected void signalError(String cmd, String errorMsg) { + signalError(cmd, errorMsg, StandardCharsets.US_ASCII); + } + + protected void signalError(String cmd, String errorMsg, Charset cs) { + log.warn("{}[{}]: {}", channel, cmd, errorMsg); + println(cmd, errorMsg, getErrorStream(), cs); + variables.put(STATUS, 1); + } + @Override public void run() { String command = null; + variables.put(STATUS, 0); + + boolean debugEnabled = log.isDebugEnabled(); try { - currentDir = opener.resolveLocalPath(channel.getSession(), fileSystem, "."); + // TODO find some better alternative + if (homeDir == null) { + currentDir = opener.resolveLocalPath(channel.getSession(), fileSystem, "."); + log.warn("run - no home dir - starting at {}", currentDir); + } else { + currentDir = homeDir; + if (debugEnabled) { + log.debug("run - starting at home dir={}", homeDir); + } + } + + prepareEnvironment(getEnvironment()); + // 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 (;;) { + for (int executedCommands = 0;; executedCommands++) { command = readLine(r); - if (command.length() == 0 || !handleCommandLine(command)) { + if (GenericUtils.isEmpty(command)) { + if (debugEnabled) { + log.debug("run({}) Command loop terminated after {} commands", channel, executedCommands); + } + + return; + } + + if (!handleCommandLine(command)) { + if (debugEnabled) { + log.debug("run({}) Command loop terminated by cmd={} after {} commands", + channel, command, executedCommands); + } return; } } } } catch (InterruptedIOException e) { - // Ignore - signaled end + if (debugEnabled) { + log.debug("run({}) interrupted after command={}", channel, command); + } } catch (Exception e) { String message = "Failed (" + e.getClass().getSimpleName() + ") to handle '" + command + "': " + e.getMessage(); + log.warn("run({}) {}", channel, message); 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()); + log.warn("run({}) Failed ({}) to write error message={}: {}", + channel, ioe.getClass().getSimpleName(), message, ioe.getMessage()); } finally { onExit(-1, message); } @@ -153,16 +234,29 @@ public class ScpShell extends AbstractFileSystemCommand { StringBuilder sb = new StringBuilder(); while (true) { int c = reader.read(); - if (c < 0 || c == '\n') { + if ((c < 0) || c == '\n') { break; } sb.append((char) c); } + + int len = sb.length(); + // Strip CR at end of line if present + if ((len > 0) && (sb.charAt(len - 1) == '\r')) { + sb.setLength(len - 1); + } + return sb.toString(); } protected boolean handleCommandLine(String command) throws Exception { + if (log.isDebugEnabled()) { + log.debug("handleCommandLine({}) {}", channel, command); + } + List<String[]> cmds = parse(command); + OutputStream stdout = getOutputStream(); + OutputStream stderr = getErrorStream(); for (String[] argv : cmds) { switch (argv[0]) { case "echo": @@ -183,21 +277,44 @@ public class ScpShell extends AbstractFileSystemCommand { case "groups": variables.put(STATUS, 0); break; + case "printenv": + printenv(argv); + break; case "unset": + unset(argv); + break; case "unalias": - case "printenv": variables.put(STATUS, 1); break; default: - variables.put(STATUS, 127); - getErrorStream().write(("command not found: " + argv[0] + "\n").getBytes()); + handleUnsupportedCommand(command, argv); } - getOutputStream().flush(); - getErrorStream().flush(); + stdout.flush(); + stderr.flush(); } + return true; } + protected void prepareEnvironment(Environment environ) { + Map<String, String> env = environ.getEnv(); + Locale locale = Locale.getDefault(); + String languageTag = locale.toLanguageTag(); + env.put(ENV_LANG, languageTag.replace('-', '_') + "." + nameEncodingCharset.displayName()); + + if (homeDir != null) { + env.put(ENV_HOME, homeDir.toString()); + } + + updatePwdEnvVariable(currentDir); + } + + protected void handleUnsupportedCommand(String command, String[] argv) throws Exception { + log.warn("handleUnsupportedCommand({}) unsupported: {}", channel, command); + variables.put(STATUS, 127); + getErrorStream().write(("command not found: " + argv[0] + "\n").getBytes(StandardCharsets.US_ASCII)); + } + protected List<String[]> parse(String command) { List<String[]> cmds = new ArrayList<>(); List<String> args = new ArrayList<>(); @@ -243,6 +360,58 @@ public class ScpShell extends AbstractFileSystemCommand { return cmds; } + protected void printenv(String[] argv) throws Exception { + Environment environ = getEnvironment(); + Map<String, String> envValues = environ.getEnv(); + OutputStream stdout = getOutputStream(); + if (argv.length == 1) { + envValues.entrySet() + .stream() + .forEach(e -> println(argv[0], e.getKey() + "=" + e.getValue(), stdout, StandardCharsets.US_ASCII)); + variables.put(STATUS, 0); + return; + } + + if (argv.length != 2) { + signalError(argv[0], "printenv: only one variable value at a time"); + return; + } + + String varName = argv[1]; + String varValue = resolveEnvironmentVariable(varName, envValues); + if (varValue == null) { + signalError(argv[0], "printenv: variable not set " + varName); + return; + } + + if (log.isDebugEnabled()) { + log.debug("printenv({}) {}={}", channel, varName, varValue); + } + + println(argv[0], varValue, stdout, StandardCharsets.US_ASCII); + variables.put(STATUS, 0); + } + + protected String resolveEnvironmentVariable(String varName, Map<String, String> envValues) { + return envValues.get(varName); + } + + protected void unset(String[] argv) throws Exception { + if (argv.length != 2) { + signalError(argv[0], "unset: exactly one argument is expected"); + return; + } + + Environment environ = getEnvironment(); + Map<String, String> envValues = environ.getEnv(); + String varName = argv[1]; + String varValue = envValues.remove(varName); + if (log.isDebugEnabled()) { + log.debug("unset({}) {}={}", channel, varName, varValue); + } + variables.put(STATUS, (varValue == null) ? 1 : 0); + } + protected void scp(String[] argv) throws Exception { boolean optR = false; boolean optT = false; @@ -252,62 +421,81 @@ public class ScpShell extends AbstractFileSystemCommand { boolean isOption = true; String path = null; for (int i = 1; i < argv.length; i++) { - if (isOption && argv[i].startsWith("-")) { - switch (argv[i]) { - case "-r": + String argVal = argv[i]; + if (GenericUtils.isEmpty(argVal)) { + signalError(argv[0], "scp: empty argument not allowed"); + return; + } + + if (isOption && (argVal.charAt(0) == '-')) { + if (argVal.length() != 2) { + signalError(argv[0], "scp: only one option at a time may be specified"); + return; + } + + // TODO should we raise an error if option re-specified ? + char optVal = argVal.charAt(1); + switch (optVal) { + case 'r': optR = true; break; - case "-t": + case 't': optT = true; break; - case "-f": + case 'f': optF = true; break; - case "-d": + case 'd': optD = true; break; - case "-p": + case 'p': optP = true; break; default: - println("scp: unsupported option: " + argv[i], getErrorStream()); - variables.put(STATUS, 1); + signalError(argv[0], "scp: unsupported option: " + argVal); return; } } else if (path == null) { - path = argv[i]; + path = argVal; isOption = false; } else { - println("scp: one and only one argument expected", getErrorStream()); - variables.put(STATUS, 1); + signalError(argv[0], "scp: one and only one path argument expected"); 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); + + if ((optT && optF) || (!optT && !optF)) { + signalError(argv[0], "scp: one and only one of -t and -f option expected"); + return; + } + + doScp(path, optR, optT, optF, optD, optP); + } + + protected void doScp( + String path, boolean optR, boolean optT, boolean optF, boolean optD, boolean optP) + throws Exception { + try { + ScpHelper helper = new ScpHelper( + channel.getSession(), getInputStream(), getOutputStream(), + fileSystem, opener, listener); + Path localPath = currentDir.resolve(path); + if (optT) { + helper.receive(localPath, optR, optD, optP, receiveBufferSize); + } else { + helper.send(Collections.singletonList(localPath.toString()), 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); } } @@ -352,87 +540,137 @@ public class ScpShell extends AbstractFileSystemCommand { } } } - println(buf, getOutputStream()); + println(argv[0], buf, getOutputStream(), nameEncodingCharset); 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); + signalError(argv[0], "pwd: too many arguments"); } else { - println(currentDir, getOutputStream()); + println(argv[0], currentDir, getOutputStream(), nameEncodingCharset); variables.put(STATUS, 0); } } protected void cd(String[] argv) throws Exception { + if (argv.length == 1) { + if (homeDir != null) { + currentDir = homeDir; + updatePwdEnvVariable(currentDir); + variables.put(STATUS, 0); + } else { + signalError(argv[0], "No home directory to return to"); + } + + return; + } + if (argv.length != 2) { - println("cd: too many or too few arguments", getErrorStream()); - variables.put(STATUS, 1); + signalError(argv[0], "cd: too many or too few arguments"); + return; + } + + String path = argv[1]; + if (GenericUtils.isEmpty(path)) { + signalError(argv[0], "cd: empty target"); + return; + } + + // TODO make sure not escaping the user's sandbox filesystem + Path cwd = currentDir; + cwd = cwd.resolve(path).toAbsolutePath().normalize(); + if (!Files.exists(cwd)) { + signalError(argv[0], "no such file or directory: " + path, nameEncodingCharset); + } else if (!Files.isDirectory(cwd)) { + signalError(argv[0], "not a directory: " + path, nameEncodingCharset); } 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); + if (log.isDebugEnabled()) { + log.debug("cd - {} => {}", currentDir, cwd); } + currentDir = cwd; + updatePwdEnvVariable(currentDir); + variables.put(STATUS, 0); } } + protected void updatePwdEnvVariable(Path pwd) { + Environment environ = getEnvironment(); + Map<String, String> envVars = environ.getEnv(); + envVars.put(ENV_PWD, pwd.toString()); + } + protected void ls(String[] argv) throws Exception { // find options - boolean a = false; - boolean l = false; - boolean f = false; + boolean optListAll = false; + boolean optLong = false; + boolean optFullTime = 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)) { + String argValue = argv[k]; + if (GenericUtils.isEmpty(argValue)) { + signalError(argv[0], "ls: empty argument not allowed"); + return; + } + + if (argValue.equals("--full-time")) { + optFullTime = true; + } else if (argValue.charAt(0) == '-') { + int argLen = argValue.length(); + if (argLen == 1) { + signalError(argv[0], "ls: no option specified"); + return; + } + + for (int i = 1; i < argLen; i++) { + char optValue = argValue.charAt(i); + // TODO should we raise an error if option re-specified ? + switch (optValue) { case 'a': - a = true; + optListAll = true; break; case 'l': - l = true; + optLong = true; break; default: - println("unsupported option: -" + argv[k].charAt(i), getErrorStream()); - variables.put(STATUS, 1); + signalError(argv[0], "unsupported option: -" + optValue); return; } } } else { - println("unsupported option: " + argv[k], getErrorStream()); - variables.put(STATUS, 1); + signalError(argv[0], "unsupported option: " + argValue); return; } } - boolean optListAll = a; - boolean optLong = l; - boolean optFullTime = f; + + doLs(argv, optListAll, optLong, optFullTime); + } + + protected void doLs( + String argv[], boolean optListAll, boolean optLong, boolean optFullTime) + throws Exception { // list current directory content - Predicate<Path> filter = p -> optListAll || p.getFileName().toString().equals(".") - || p.getFileName().toString().equals("..") || !p.getFileName().toString().startsWith("."); + Predicate<Path> filter = p -> { + String fileName = p.getFileName().toString(); + return optListAll || fileName.equals(".") + || fileName.equals("..") || !fileName.startsWith("."); + }; + + // TODO make sure not listing above user's home directory String[] synth = currentDir.toString().equals("/") ? new String[] { "." } : new String[] { ".", ".." }; + OutputStream stdout = getOutputStream(); 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())); + .forEach(str -> println(argv[0], str, stdout, nameEncodingCharset)); variables.put(STATUS, 0); } protected static class PathEntry implements Comparable<PathEntry> { + public static final DateTimeFormatter FULL_TIME_VALUE_FORMATTER = DateTimeFormatter.ofPattern("MMM ppd HH:mm:ss yyyy"); + public static final DateTimeFormatter TIME_ONLY_VALUE_FORMATTER = DateTimeFormatter.ofPattern("MMM ppd HH:mm"); + public static final DateTimeFormatter YEAR_VALUE_FORMATTER = DateTimeFormatter.ofPattern("MMM ppd yyyy"); protected final Path abs; protected final Path path; @@ -449,68 +687,80 @@ public class ScpShell extends AbstractFileSystemCommand { return path.toString().compareTo(o.path.toString()); } + @Override + public String toString() { + return Objects.toString(abs); + } + 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(); + String abbrev = shortDisplay(); + if (!optLongDisplay) { + return abbrev; + } + + StringBuilder sb = new StringBuilder(abbrev.length() + 64); + if (is("isDirectory")) { + sb.append('d'); + } else if (is("isSymbolicLink")) { + sb.append('l'); + } else if (is("isOther")) { + sb.append('o'); } else { - return shortDisplay(); + sb.append('-'); + } + + @SuppressWarnings("unchecked") + Set<PosixFilePermission> perms = (Set<PosixFilePermission>) attributes.get("permissions"); + if (perms == null) { + perms = EnumSet.noneOf(PosixFilePermission.class); } + sb.append(PosixFilePermissions.toString(perms)); + + Object nlinkValue = attributes.get("nlink"); + sb.append(' ').append(String.format("%3s", (nlinkValue != null) ? nlinkValue : "1")); + + appendOwnerInformation(sb, "owner", "owner"); + appendOwnerInformation(sb, "group", "group"); + + Number length = (Number) attributes.get("size"); + if (length == null) { + length = 0L; + } + sb.append(' ').append(String.format("%1$8s", length)); + + String timeValue = toString((FileTime) attributes.get("lastModifiedTime"), optFullTime); + sb.append(' ').append(timeValue); + + sb.append(' ').append(abbrev); + return sb.toString(); } protected boolean is(String attr) { Object d = attributes.get(attr); - return d instanceof Boolean && (Boolean) d; + return (d instanceof Boolean) && (Boolean) d; + } + + protected StringBuilder appendOwnerInformation( + StringBuilder sb, String attr, String defaultValue) { + String owner = Objects.toString(attributes.get(attr), null); + if (GenericUtils.isEmpty(owner)) { + owner = defaultValue; + } + if (owner.length() > 8) { + owner = owner.substring(0, 8); + } + sb.append(' ').append(owner); + for (int index = owner.length(); index < 8; index++) { + sb.append(' '); + } + return sb; } protected String shortDisplay() { if (is("isSymbolicLink")) { try { Path l = Files.readSymbolicLink(abs); - return path.toString() + " -> " + l.toString(); + return path + " -> " + l; } catch (IOException e) { // ignore } @@ -518,80 +768,38 @@ public class ScpShell extends AbstractFileSystemCommand { return path.toString(); } - protected String toString(FileTime time, boolean optFullTime) { + protected static 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); + return FULL_TIME_VALUE_FORMATTER.format(dt); } else if (System.currentTimeMillis() - millis < 183L * 24L * 60L * 60L * 1000L) { - return DateTimeFormatter.ofPattern("MMM ppd HH:mm").format(dt); + return TIME_ONLY_VALUE_FORMATTER.format(dt); } else { - return DateTimeFormatter.ofPattern("MMM ppd yyyy").format(dt); + return YEAR_VALUE_FORMATTER.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()) { + FileSystem fs = path.getFileSystem(); + Collection<String> views = fs.supportedFileAttributeViews(); + for (String view : views) { try { - Map<String, Object> ta = Files.readAttributes(path, view + ":*", EMPTY_LINK_OPTIONS); + Map<String, Object> ta = Files.readAttributes( + path, view + ":*", IoUtils.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())); + attrs.computeIfAbsent("permissions", s -> IoUtils.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-sftp/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java b/sshd-sftp/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java index e2c05b6..e155ff1 100644 --- a/sshd-sftp/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java +++ b/sshd-sftp/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java @@ -74,8 +74,10 @@ import org.apache.sshd.common.PropertyResolverUtils; import org.apache.sshd.common.SshConstants; import org.apache.sshd.common.channel.WindowClosedException; import org.apache.sshd.common.channel.exception.SshChannelClosedException; +import org.apache.sshd.common.file.FileSystemFactory; import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory; import org.apache.sshd.common.random.Random; +import org.apache.sshd.common.session.SessionContext; import org.apache.sshd.common.subsystem.sftp.SftpConstants; import org.apache.sshd.common.subsystem.sftp.SftpException; import org.apache.sshd.common.subsystem.sftp.extensions.AclSupportedParser.AclCapabilities; @@ -254,7 +256,17 @@ public class SftpTest extends AbstractSftpClientTestSupport { public void testNavigateBeyondRootFolder() throws Exception { Path rootLocation = Paths.get(OsUtils.isUNIX() ? "/" : "C:\\"); FileSystem fsRoot = rootLocation.getFileSystem(); - sshd.setFileSystemFactory(session1 -> fsRoot); + sshd.setFileSystemFactory(new FileSystemFactory() { + @Override + public Path getUserHomeDir(SessionContext session) throws IOException { + return rootLocation; + } + + @Override + public FileSystem createFileSystem(SessionContext session) throws IOException { + return fsRoot; + } + }); try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port) .verify(CONNECT_TIMEOUT).getSession()) {