| /* |
| * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> |
| * and other copyright owners as documented in the project's IP log. |
| * |
| * This program and the accompanying materials are made available |
| * under the terms of the Eclipse Distribution License v1.0 which |
| * accompanies this distribution, is reproduced below, and is |
| * available at http://www.eclipse.org/org/documents/edl-v10.php |
| * |
| * All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or |
| * without modification, are permitted provided that the following |
| * conditions are met: |
| * |
| * - Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * |
| * - Redistributions in binary form must reproduce the above |
| * copyright notice, this list of conditions and the following |
| * disclaimer in the documentation and/or other materials provided |
| * with the distribution. |
| * |
| * - Neither the name of the Eclipse Foundation, Inc. nor the |
| * names of its contributors may be used to endorse or promote |
| * products derived from this software without specific prior |
| * written permission. |
| * |
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND |
| * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, |
| * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES |
| * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
| * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
| * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
| * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
| * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER |
| * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, |
| * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF |
| * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| package org.eclipse.jgit.transport.sshd; |
| |
| import static java.text.MessageFormat.format; |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InterruptedIOException; |
| import java.io.OutputStream; |
| import java.time.Duration; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.EnumSet; |
| import java.util.List; |
| import java.util.concurrent.CopyOnWriteArrayList; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.atomic.AtomicReference; |
| import java.util.function.Supplier; |
| |
| import org.apache.sshd.client.SshClient; |
| import org.apache.sshd.client.channel.ChannelExec; |
| import org.apache.sshd.client.channel.ClientChannelEvent; |
| import org.apache.sshd.client.session.ClientSession; |
| import org.apache.sshd.client.subsystem.sftp.SftpClient; |
| import org.apache.sshd.client.subsystem.sftp.SftpClient.CloseableHandle; |
| import org.apache.sshd.client.subsystem.sftp.SftpClient.CopyMode; |
| import org.apache.sshd.client.subsystem.sftp.SftpClientFactory; |
| import org.apache.sshd.common.session.Session; |
| import org.apache.sshd.common.session.SessionListener; |
| import org.apache.sshd.common.subsystem.sftp.SftpException; |
| import org.eclipse.jgit.annotations.NonNull; |
| import org.eclipse.jgit.internal.transport.sshd.SshdText; |
| import org.eclipse.jgit.transport.FtpChannel; |
| import org.eclipse.jgit.transport.RemoteSession; |
| import org.eclipse.jgit.transport.URIish; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| /** |
| * An implementation of {@link RemoteSession} based on Apache MINA sshd. |
| * |
| * @since 5.2 |
| */ |
| public class SshdSession implements RemoteSession { |
| |
| private static final Logger LOG = LoggerFactory |
| .getLogger(SshdSession.class); |
| |
| private final CopyOnWriteArrayList<SessionCloseListener> listeners = new CopyOnWriteArrayList<>(); |
| |
| private final URIish uri; |
| |
| private SshClient client; |
| |
| private ClientSession session; |
| |
| SshdSession(URIish uri, Supplier<SshClient> clientFactory) { |
| this.uri = uri; |
| this.client = clientFactory.get(); |
| } |
| |
| void connect(Duration timeout) throws IOException { |
| if (!client.isStarted()) { |
| client.start(); |
| } |
| try { |
| String username = uri.getUser(); |
| String host = uri.getHost(); |
| int port = uri.getPort(); |
| long t = timeout.toMillis(); |
| if (t <= 0) { |
| session = client.connect(username, host, port).verify() |
| .getSession(); |
| } else { |
| session = client.connect(username, host, port) |
| .verify(timeout.toMillis()).getSession(); |
| } |
| session.addSessionListener(new SessionListener() { |
| |
| @Override |
| public void sessionClosed(Session s) { |
| notifyCloseListeners(); |
| } |
| }); |
| // Authentication timeout is by default 2 minutes. |
| session.auth().verify(session.getAuthTimeout()); |
| } catch (IOException e) { |
| disconnect(e); |
| throw e; |
| } |
| } |
| |
| /** |
| * Adds a {@link SessionCloseListener} to this session. Has no effect if the |
| * given {@code listener} is already registered with this session. |
| * |
| * @param listener |
| * to add |
| */ |
| public void addCloseListener(@NonNull SessionCloseListener listener) { |
| listeners.addIfAbsent(listener); |
| } |
| |
| /** |
| * Removes the given {@code listener}; has no effect if the listener is not |
| * currently registered with this session. |
| * |
| * @param listener |
| * to remove |
| */ |
| public void removeCloseListener(@NonNull SessionCloseListener listener) { |
| listeners.remove(listener); |
| } |
| |
| private void notifyCloseListeners() { |
| for (SessionCloseListener l : listeners) { |
| try { |
| l.sessionClosed(this); |
| } catch (RuntimeException e) { |
| LOG.warn(SshdText.get().closeListenerFailed, e); |
| } |
| } |
| } |
| |
| @Override |
| public Process exec(String commandName, int timeout) throws IOException { |
| @SuppressWarnings("resource") |
| ChannelExec exec = session.createExecChannel(commandName); |
| long timeoutMillis = TimeUnit.SECONDS.toMillis(timeout); |
| try { |
| if (timeout <= 0) { |
| exec.open().verify(); |
| } else { |
| long start = System.nanoTime(); |
| exec.open().verify(timeoutMillis); |
| timeoutMillis -= TimeUnit.NANOSECONDS |
| .toMillis(System.nanoTime() - start); |
| } |
| } catch (IOException | RuntimeException e) { |
| exec.close(true); |
| throw e; |
| } |
| if (timeout > 0 && timeoutMillis <= 0) { |
| // We have used up the whole timeout for opening the channel |
| exec.close(true); |
| throw new InterruptedIOException( |
| format(SshdText.get().sshCommandTimeout, commandName, |
| Integer.valueOf(timeout))); |
| } |
| return new SshdExecProcess(exec, commandName, timeoutMillis); |
| } |
| |
| /** |
| * Obtain an {@link FtpChannel} to perform SFTP operations in this |
| * {@link SshdSession}. |
| */ |
| @Override |
| @NonNull |
| public FtpChannel getFtpChannel() { |
| return new SshdFtpChannel(); |
| } |
| |
| @Override |
| public void disconnect() { |
| disconnect(null); |
| } |
| |
| private void disconnect(Throwable reason) { |
| try { |
| if (session != null) { |
| session.close(); |
| session = null; |
| } |
| } catch (IOException e) { |
| if (reason != null) { |
| reason.addSuppressed(e); |
| } else { |
| LOG.error(SshdText.get().sessionCloseFailed, e); |
| } |
| } finally { |
| client.stop(); |
| client = null; |
| } |
| } |
| |
| private static class SshdExecProcess extends Process { |
| |
| private final ChannelExec channel; |
| |
| private final long timeoutMillis; |
| |
| private final String commandName; |
| |
| public SshdExecProcess(ChannelExec channel, String commandName, |
| long timeoutMillis) { |
| this.channel = channel; |
| this.timeoutMillis = timeoutMillis > 0 ? timeoutMillis : -1L; |
| this.commandName = commandName; |
| } |
| |
| @Override |
| public OutputStream getOutputStream() { |
| return channel.getInvertedIn(); |
| } |
| |
| @Override |
| public InputStream getInputStream() { |
| return channel.getInvertedOut(); |
| } |
| |
| @Override |
| public InputStream getErrorStream() { |
| return channel.getInvertedErr(); |
| } |
| |
| @Override |
| public int waitFor() throws InterruptedException { |
| if (waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) { |
| return exitValue(); |
| } |
| return -1; |
| } |
| |
| @Override |
| public boolean waitFor(long timeout, TimeUnit unit) |
| throws InterruptedException { |
| long millis = timeout >= 0 ? unit.toMillis(timeout) : -1L; |
| return channel |
| .waitFor(EnumSet.of(ClientChannelEvent.CLOSED), millis) |
| .contains(ClientChannelEvent.CLOSED); |
| } |
| |
| @Override |
| public int exitValue() { |
| Integer exitCode = channel.getExitStatus(); |
| if (exitCode == null) { |
| throw new IllegalThreadStateException( |
| format(SshdText.get().sshProcessStillRunning, |
| commandName)); |
| } |
| return exitCode.intValue(); |
| } |
| |
| @Override |
| public void destroy() { |
| if (channel.isOpen()) { |
| channel.close(true); |
| } |
| } |
| } |
| |
| /** |
| * Helper interface like {@link Supplier}, but possibly raising an |
| * {@link IOException}. |
| * |
| * @param <T> |
| * return type |
| */ |
| @FunctionalInterface |
| private interface FtpOperation<T> { |
| |
| T call() throws IOException; |
| |
| } |
| |
| private class SshdFtpChannel implements FtpChannel { |
| |
| private SftpClient ftp; |
| |
| /** Current working directory. */ |
| private String cwd = ""; //$NON-NLS-1$ |
| |
| @Override |
| public void connect(int timeout, TimeUnit unit) throws IOException { |
| if (timeout <= 0) { |
| session.getProperties().put( |
| SftpClient.SFTP_CHANNEL_OPEN_TIMEOUT, |
| Long.valueOf(Long.MAX_VALUE)); |
| } else { |
| session.getProperties().put( |
| SftpClient.SFTP_CHANNEL_OPEN_TIMEOUT, |
| Long.valueOf(unit.toMillis(timeout))); |
| } |
| ftp = SftpClientFactory.instance().createSftpClient(session); |
| try { |
| cd(cwd); |
| } catch (IOException e) { |
| ftp.close(); |
| } |
| } |
| |
| @Override |
| public void disconnect() { |
| try { |
| ftp.close(); |
| } catch (IOException e) { |
| LOG.error(SshdText.get().ftpCloseFailed, e); |
| } |
| } |
| |
| @Override |
| public boolean isConnected() { |
| return session.isAuthenticated() && ftp.isOpen(); |
| } |
| |
| private String absolute(String path) { |
| if (path.isEmpty()) { |
| return cwd; |
| } |
| // Note: there is no path injection vulnerability here. If |
| // path has too many ".." components, we rely on the server |
| // catching it and returning an error. |
| if (path.charAt(0) != '/') { |
| if (cwd.charAt(cwd.length() - 1) == '/') { |
| return cwd + path; |
| } else { |
| return cwd + '/' + path; |
| } |
| } |
| return path; |
| } |
| |
| private <T> T map(FtpOperation<T> op) throws IOException { |
| try { |
| return op.call(); |
| } catch (IOException e) { |
| if (e instanceof SftpException) { |
| throw new FtpChannel.FtpException(e.getLocalizedMessage(), |
| ((SftpException) e).getStatus(), e); |
| } |
| throw e; |
| } |
| } |
| |
| @Override |
| public void cd(String path) throws IOException { |
| cwd = map(() -> ftp.canonicalPath(absolute(path))); |
| if (cwd.isEmpty()) { |
| cwd += '/'; |
| } |
| } |
| |
| @Override |
| public String pwd() throws IOException { |
| return cwd; |
| } |
| |
| @Override |
| public Collection<DirEntry> ls(String path) throws IOException { |
| return map(() -> { |
| List<DirEntry> result = new ArrayList<>(); |
| try (CloseableHandle handle = ftp.openDir(absolute(path))) { |
| AtomicReference<Boolean> atEnd = new AtomicReference<>( |
| Boolean.FALSE); |
| while (!atEnd.get().booleanValue()) { |
| List<SftpClient.DirEntry> chunk = ftp.readDir(handle, |
| atEnd); |
| if (chunk == null) { |
| break; |
| } |
| for (SftpClient.DirEntry remote : chunk) { |
| result.add(new DirEntry() { |
| |
| @Override |
| public String getFilename() { |
| return remote.getFilename(); |
| } |
| |
| @Override |
| public long getModifiedTime() { |
| return remote.getAttributes() |
| .getModifyTime().toMillis(); |
| } |
| |
| @Override |
| public boolean isDirectory() { |
| return remote.getAttributes().isDirectory(); |
| } |
| |
| }); |
| } |
| } |
| } |
| return result; |
| }); |
| } |
| |
| @Override |
| public void rmdir(String path) throws IOException { |
| map(() -> { |
| ftp.rmdir(absolute(path)); |
| return null; |
| }); |
| |
| } |
| |
| @Override |
| public void mkdir(String path) throws IOException { |
| map(() -> { |
| ftp.mkdir(absolute(path)); |
| return null; |
| }); |
| } |
| |
| @Override |
| public InputStream get(String path) throws IOException { |
| return map(() -> ftp.read(absolute(path))); |
| } |
| |
| @Override |
| public OutputStream put(String path) throws IOException { |
| return map(() -> ftp.write(absolute(path))); |
| } |
| |
| @Override |
| public void rm(String path) throws IOException { |
| map(() -> { |
| ftp.remove(absolute(path)); |
| return null; |
| }); |
| } |
| |
| @Override |
| public void rename(String from, String to) throws IOException { |
| map(() -> { |
| String src = absolute(from); |
| String dest = absolute(to); |
| try { |
| ftp.rename(src, dest, CopyMode.Atomic, CopyMode.Overwrite); |
| } catch (UnsupportedOperationException e) { |
| // Older server cannot do POSIX rename... |
| if (!src.equals(dest)) { |
| delete(dest); |
| ftp.rename(src, dest); |
| } |
| } |
| return null; |
| }); |
| } |
| } |
| } |