001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.commons.vfs2.provider.sftp; 018 019import java.io.IOException; 020import java.io.InputStreamReader; 021import java.nio.charset.StandardCharsets; 022import java.time.Duration; 023import java.util.Arrays; 024import java.util.Collection; 025import java.util.Objects; 026 027import org.apache.commons.lang3.time.DurationUtils; 028import org.apache.commons.logging.Log; 029import org.apache.commons.logging.LogFactory; 030import org.apache.commons.vfs2.Capability; 031import org.apache.commons.vfs2.FileObject; 032import org.apache.commons.vfs2.FileSystemException; 033import org.apache.commons.vfs2.FileSystemOptions; 034import org.apache.commons.vfs2.provider.AbstractFileName; 035import org.apache.commons.vfs2.provider.AbstractFileSystem; 036import org.apache.commons.vfs2.provider.GenericFileName; 037 038import com.jcraft.jsch.ChannelExec; 039import com.jcraft.jsch.ChannelSftp; 040import com.jcraft.jsch.JSchException; 041import com.jcraft.jsch.Session; 042import com.jcraft.jsch.SftpException; 043 044/** 045 * Represents the files on an SFTP server. 046 */ 047public class SftpFileSystem extends AbstractFileSystem { 048 049 private static final Log LOG = LogFactory.getLog(SftpFileSystem.class); 050 051 private static final int UNIDENTIFIED = -1; 052 053 private static final int SLEEP_MILLIS = 100; 054 055 private static final int EXEC_BUFFER_SIZE = 128; 056 057 private static final long LAST_MOD_TIME_ACCURACY = 1000L; 058 059 /** 060 * Session; never null. 061 * <p> 062 * DCL pattern requires that the ivar be volatile. 063 * </p> 064 */ 065 private volatile Session session; 066 067 private volatile ChannelSftp idleChannel; 068 069 private final Duration connectTimeout; 070 071 /** 072 * Cache for the user ID (-1 when not set) 073 * <p> 074 * DCL pattern requires that the ivar be volatile. 075 * </p> 076 */ 077 private volatile int uid = UNIDENTIFIED; 078 079 /** 080 * Cache for the user groups ids (null when not set) 081 * <p> 082 * DCL pattern requires that the ivar be volatile. 083 * </p> 084 */ 085 private volatile int[] groupsIds; 086 087 /** 088 * Some SFTP-only servers disable the exec channel. When exec is disabled, things like getUId() will always fail. 089 */ 090 private final boolean execDisabled; 091 092 /** 093 * Constructs a new instance. 094 * 095 * @param rootName The root file name of this file system. 096 * @param session The session. 097 * @param fileSystemOptions Options to build this file system. 098 */ 099 protected SftpFileSystem(final GenericFileName rootName, final Session session, final FileSystemOptions fileSystemOptions) { 100 super(rootName, null, fileSystemOptions); 101 this.session = Objects.requireNonNull(session, "session"); 102 connectTimeout = SftpFileSystemConfigBuilder.getInstance().getConnectTimeout(fileSystemOptions); 103 if (SftpFileSystemConfigBuilder.getInstance().isDisableDetectExecChannel(fileSystemOptions)) { 104 execDisabled = true; 105 } else { 106 execDisabled = detectExecDisabled(); 107 } 108 } 109 110 /** 111 * Adds the capabilities of this file system. 112 */ 113 @Override 114 protected void addCapabilities(final Collection<Capability> caps) { 115 caps.addAll(SftpFileProvider.capabilities); 116 } 117 118 /** 119 * Creates a file object. This method is called only if the requested file is not cached. 120 */ 121 @Override 122 protected FileObject createFile(final AbstractFileName name) throws FileSystemException { 123 return new SftpFileObject(name, this); 124 } 125 126 /** 127 * Some SFTP-only servers disable the exec channel. 128 * 129 * Attempt to detect this by calling getUid. 130 */ 131 private boolean detectExecDisabled() { 132 try { 133 return getUId() == UNIDENTIFIED; 134 } catch (final JSchException | IOException e) { 135 LOG.debug("Cannot get UID, assuming no exec channel is present", e); 136 return true; 137 } 138 } 139 140 @Override 141 protected void doCloseCommunicationLink() { 142 if (idleChannel != null) { 143 synchronized (this) { 144 if (idleChannel != null) { 145 idleChannel.disconnect(); 146 idleChannel = null; 147 } 148 } 149 } 150 151 if (session != null) { 152 session.disconnect(); 153 } 154 } 155 156 /** 157 * Executes a command and returns the (standard) output through a StringBuilder. 158 * 159 * @param command The command 160 * @param output The output 161 * @return The exit code of the command 162 * @throws JSchException if a JSch error is detected. 163 * @throws FileSystemException if a session cannot be created. 164 * @throws IOException if an I/O error is detected. 165 */ 166 private int executeCommand(final String command, final StringBuilder output) throws JSchException, IOException { 167 final ChannelExec channel = (ChannelExec) getSession().openChannel("exec"); 168 try { 169 channel.setCommand(command); 170 channel.setInputStream(null); 171 try (InputStreamReader stream = new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8)) { 172 channel.setErrStream(System.err, true); 173 channel.connect(DurationUtils.toMillisInt(connectTimeout)); 174 175 // Read the stream 176 final char[] buffer = new char[EXEC_BUFFER_SIZE]; 177 int read; 178 while ((read = stream.read(buffer, 0, buffer.length)) >= 0) { 179 output.append(buffer, 0, read); 180 } 181 } 182 183 // Wait until the command finishes (should not be long since we read the output stream) 184 while (!channel.isClosed()) { 185 try { 186 Thread.sleep(SLEEP_MILLIS); 187 } catch (final InterruptedException e) { 188 // Someone asked us to stop. 189 break; 190 } 191 } 192 } finally { 193 channel.disconnect(); 194 } 195 return channel.getExitStatus(); 196 } 197 198 /** 199 * Returns an SFTP channel to the server. 200 * 201 * @return new or reused channel, never null. 202 * @throws FileSystemException if a session cannot be created. 203 * @throws IOException if an I/O error is detected. 204 */ 205 protected ChannelSftp getChannel() throws IOException { 206 try { 207 // Use the pooled channel, or create a new one 208 ChannelSftp channel = null; 209 if (idleChannel != null) { 210 synchronized (this) { 211 if (idleChannel != null) { 212 channel = idleChannel; 213 idleChannel = null; 214 } 215 } 216 } 217 218 if (channel == null) { 219 channel = (ChannelSftp) getSession().openChannel("sftp"); 220 channel.connect(DurationUtils.toMillisInt(connectTimeout)); 221 final Boolean userDirIsRoot = SftpFileSystemConfigBuilder.getInstance() 222 .getUserDirIsRoot(getFileSystemOptions()); 223 final String workingDirectory = getRootName().getPath(); 224 if (workingDirectory != null && (userDirIsRoot == null || !userDirIsRoot.booleanValue())) { 225 try { 226 channel.cd(workingDirectory); 227 } catch (final SftpException e) { 228 throw new FileSystemException("vfs.provider.sftp/change-work-directory.error", workingDirectory, 229 e); 230 } 231 } 232 } 233 234 final String fileNameEncoding = SftpFileSystemConfigBuilder.getInstance() 235 .getFileNameEncoding(getFileSystemOptions()); 236 237 if (fileNameEncoding != null) { 238 try { 239 channel.setFilenameEncoding(fileNameEncoding); 240 } catch (final SftpException e) { 241 throw new FileSystemException("vfs.provider.sftp/filename-encoding.error", fileNameEncoding); 242 } 243 } 244 return channel; 245 } catch (final JSchException e) { 246 throw new FileSystemException("vfs.provider.sftp/connect.error", getRootName().getFriendlyURI(), e); 247 } 248 } 249 250 /** 251 * Gets the (numeric) group IDs. 252 * 253 * @return the (numeric) group IDs. 254 * @throws JSchException If a problem occurs while retrieving the group IDs. 255 * @throws IOException if an I/O error is detected. 256 * @since 2.1 257 */ 258 public int[] getGroupsIds() throws JSchException, IOException { 259 if (groupsIds == null) { 260 synchronized (this) { 261 // DCL pattern requires that the ivar be volatile. 262 if (groupsIds == null) { 263 final StringBuilder output = new StringBuilder(); 264 final int code = executeCommand("id -G", output); 265 if (code != 0) { 266 throw new JSchException("Could not get the groups id of the current user (error code: " + code + ")"); 267 } 268 groupsIds = parseGroupIdOutput(output); 269 } 270 } 271 } 272 return groupsIds; 273 } 274 275 /** 276 * Last modification time is only an int and in seconds, thus can be off by 999. 277 * 278 * @return 1000 279 */ 280 @Override 281 public double getLastModTimeAccuracy() { 282 return LAST_MOD_TIME_ACCURACY; 283 } 284 285 /** 286 * Ensures that the session link is established. 287 * 288 * @throws FileSystemException if a session cannot be created. 289 */ 290 private Session getSession() throws FileSystemException { 291 if (!session.isConnected()) { 292 synchronized (this) { 293 if (!session.isConnected()) { 294 doCloseCommunicationLink(); 295 session = SftpFileProvider.createSession((GenericFileName) getRootName(), 296 getFileSystemOptions()); 297 } 298 } 299 } 300 return session; 301 } 302 303 /** 304 * Gets the (numeric) group IDs. 305 * 306 * @return The numeric user ID 307 * @throws JSchException If a problem occurs while retrieving the group ID. 308 * @throws IOException if an I/O error is detected. 309 * @since 2.1 310 */ 311 public int getUId() throws JSchException, IOException { 312 if (uid == UNIDENTIFIED) { 313 synchronized (this) { 314 if (uid == UNIDENTIFIED) { 315 final StringBuilder output = new StringBuilder(); 316 final int code = executeCommand("id -u", output); 317 if (code != 0) { 318 throw new FileSystemException( 319 "Could not get the user id of the current user (error code: " + code + ")"); 320 } 321 final String uidString = output.toString().trim(); 322 try { 323 uid = Integer.parseInt(uidString); 324 } catch (final NumberFormatException e) { 325 LOG.debug("Cannot convert UID to integer: '" + uidString + "'", e); 326 } 327 } 328 } 329 } 330 return uid; 331 } 332 333 /** 334 * Tests whether the exec channel is disabled. 335 * 336 * @return Whether the exec channel is disabled. 337 * @see SftpFileSystem#execDisabled 338 */ 339 public boolean isExecDisabled() { 340 return execDisabled; 341 } 342 343 /** 344 * Parses the output of the 'id -G' command 345 * 346 * @param output The output from the command 347 * @return the (numeric) group IDs. 348 */ 349 int[] parseGroupIdOutput(final StringBuilder output) { 350 // Retrieve the different groups 351 final String[] groups = output.toString().trim().split("\\s+"); 352 // Deal with potential empty groups 353 return Arrays.stream(groups).map(String::trim).filter(s -> !s.isEmpty()).mapToInt(Integer::parseInt).toArray(); 354 } 355 356 /** 357 * Returns a channel to the pool. 358 * 359 * @param channelSftp the SFTP channel. 360 */ 361 protected void putChannel(final ChannelSftp channelSftp) { 362 if (idleChannel == null) { 363 synchronized (this) { 364 if (idleChannel == null) { 365 // put back the channel only if it is still connected 366 if (channelSftp.isConnected() && !channelSftp.isClosed()) { 367 idleChannel = channelSftp; 368 } 369 } else { 370 channelSftp.disconnect(); 371 } 372 } 373 } else { 374 channelSftp.disconnect(); 375 } 376 } 377 378}