TransportSftp.java

/*
 * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org> and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */

package org.eclipse.jgit.transport;

import static org.eclipse.jgit.lib.Constants.INFO_ALTERNATES;
import static org.eclipse.jgit.lib.Constants.LOCK_SUFFIX;
import static org.eclipse.jgit.lib.Constants.OBJECTS;

import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import org.eclipse.jgit.errors.NotSupportedException;
import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectIdRef;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Ref.Storage;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.SymbolicRef;

/**
 * Transport over the non-Git aware SFTP (SSH based FTP) protocol.
 * <p>
 * The SFTP transport does not require any specialized Git support on the remote
 * (server side) repository. Object files are retrieved directly through secure
 * shell's FTP protocol, making it possible to copy objects from a remote
 * repository that is available over SSH, but whose remote host does not have
 * Git installed.
 * <p>
 * Unlike the HTTP variant (see
 * {@link org.eclipse.jgit.transport.TransportHttp}) we rely upon being able to
 * list files in directories, as the SFTP protocol supports this function. By
 * listing files through SFTP we can avoid needing to have current
 * <code>objects/info/packs</code> or <code>info/refs</code> files on the remote
 * repository and access the data directly, much as Git itself would.
 * <p>
 * Concurrent pushing over this transport is not supported. Multiple concurrent
 * push operations may cause confusion in the repository state.
 *
 * @see WalkFetchConnection
 */
public class TransportSftp extends SshTransport implements WalkTransport {
	static final TransportProtocol PROTO_SFTP = new TransportProtocol() {
		@Override
		public String getName() {
			return JGitText.get().transportProtoSFTP;
		}

		@Override
		public Set<String> getSchemes() {
			return Collections.singleton("sftp"); //$NON-NLS-1$
		}

		@Override
		public Set<URIishField> getRequiredFields() {
			return Collections.unmodifiableSet(EnumSet.of(URIishField.HOST,
					URIishField.PATH));
		}

		@Override
		public Set<URIishField> getOptionalFields() {
			return Collections.unmodifiableSet(EnumSet.of(URIishField.USER,
					URIishField.PASS, URIishField.PORT));
		}

		@Override
		public int getDefaultPort() {
			return 22;
		}

		@Override
		public Transport open(URIish uri, Repository local, String remoteName)
				throws NotSupportedException {
			return new TransportSftp(local, uri);
		}
	};

	TransportSftp(Repository local, URIish uri) {
		super(local, uri);
	}

	/** {@inheritDoc} */
	@Override
	public FetchConnection openFetch() throws TransportException {
		final SftpObjectDB c = new SftpObjectDB(uri.getPath());
		final WalkFetchConnection r = new WalkFetchConnection(this, c);
		r.available(c.readAdvertisedRefs());
		return r;
	}

	/** {@inheritDoc} */
	@Override
	public PushConnection openPush() throws TransportException {
		final SftpObjectDB c = new SftpObjectDB(uri.getPath());
		final WalkPushConnection r = new WalkPushConnection(this, c);
		r.available(c.readAdvertisedRefs());
		return r;
	}

	FtpChannel newSftp() throws IOException {
		FtpChannel channel = getSession().getFtpChannel();
		channel.connect(getTimeout(), TimeUnit.SECONDS);
		return channel;
	}

	class SftpObjectDB extends WalkRemoteObjectDatabase {
		private final String objectsPath;

		private FtpChannel ftp;

		SftpObjectDB(String path) throws TransportException {
			if (path.startsWith("/~")) //$NON-NLS-1$
				path = path.substring(1);
			if (path.startsWith("~/")) //$NON-NLS-1$
				path = path.substring(2);
			try {
				ftp = newSftp();
				ftp.cd(path);
				ftp.cd(OBJECTS);
				objectsPath = ftp.pwd();
			} catch (FtpChannel.FtpException f) {
				throw new TransportException(MessageFormat.format(
						JGitText.get().cannotEnterObjectsPath, path,
						f.getMessage()), f);
			} catch (IOException ioe) {
				close();
				throw new TransportException(uri, ioe.getMessage(), ioe);
			}
		}

		SftpObjectDB(SftpObjectDB parent, String p)
				throws TransportException {
			try {
				ftp = newSftp();
				ftp.cd(parent.objectsPath);
				ftp.cd(p);
				objectsPath = ftp.pwd();
			} catch (FtpChannel.FtpException f) {
				throw new TransportException(MessageFormat.format(
						JGitText.get().cannotEnterPathFromParent, p,
						parent.objectsPath, f.getMessage()), f);
			} catch (IOException ioe) {
				close();
				throw new TransportException(uri, ioe.getMessage(), ioe);
			}
		}

		@Override
		URIish getURI() {
			return uri.setPath(objectsPath);
		}

		@Override
		Collection<WalkRemoteObjectDatabase> getAlternates() throws IOException {
			try {
				return readAlternates(INFO_ALTERNATES);
			} catch (FileNotFoundException err) {
				return null;
			}
		}

		@Override
		WalkRemoteObjectDatabase openAlternate(String location)
				throws IOException {
			return new SftpObjectDB(this, location);
		}

		@Override
		Collection<String> getPackNames() throws IOException {
			final List<String> packs = new ArrayList<>();
			try {
				Collection<FtpChannel.DirEntry> list = ftp.ls("pack"); //$NON-NLS-1$
				Set<String> files = list.stream()
						.map(FtpChannel.DirEntry::getFilename)
						.collect(Collectors.toSet());
				HashMap<String, Long> mtimes = new HashMap<>();

				for (FtpChannel.DirEntry ent : list) {
					String n = ent.getFilename();
					if (!n.startsWith("pack-") || !n.endsWith(".pack")) { //$NON-NLS-1$ //$NON-NLS-2$
						continue;
					}
					String in = n.substring(0, n.length() - 5) + ".idx"; //$NON-NLS-1$
					if (!files.contains(in)) {
						continue;
					}
					mtimes.put(n, Long.valueOf(ent.getModifiedTime()));
					packs.add(n);
				}

				Collections.sort(packs,
						(o1, o2) -> mtimes.get(o2).compareTo(mtimes.get(o1)));
			} catch (FtpChannel.FtpException f) {
				throw new TransportException(
						MessageFormat.format(JGitText.get().cannotListPackPath,
								objectsPath, f.getMessage()),
						f);
			}
			return packs;
		}

		@Override
		FileStream open(String path) throws IOException {
			try {
				return new FileStream(ftp.get(path));
			} catch (FtpChannel.FtpException f) {
				if (f.getStatus() == FtpChannel.FtpException.NO_SUCH_FILE) {
					throw new FileNotFoundException(path);
				}
				throw new TransportException(MessageFormat.format(
						JGitText.get().cannotGetObjectsPath, objectsPath, path,
						f.getMessage()), f);
			}
		}

		@Override
		void deleteFile(String path) throws IOException {
			try {
				ftp.delete(path);
			} catch (FtpChannel.FtpException f) {
				throw new TransportException(MessageFormat.format(
						JGitText.get().cannotDeleteObjectsPath, objectsPath,
						path, f.getMessage()), f);
			}

			// Prune any now empty directories.
			//
			String dir = path;
			int s = dir.lastIndexOf('/');
			while (s > 0) {
				try {
					dir = dir.substring(0, s);
					ftp.rmdir(dir);
					s = dir.lastIndexOf('/');
				} catch (IOException je) {
					// If we cannot delete it, leave it alone. It may have
					// entries still in it, or maybe we lack write access on
					// the parent. Either way it isn't a fatal error.
					//
					break;
				}
			}
		}

		@Override
		OutputStream writeFile(String path, ProgressMonitor monitor,
				String monitorTask) throws IOException {
			Throwable err = null;
			try {
				return ftp.put(path);
			} catch (FileNotFoundException e) {
				mkdir_p(path);
			} catch (FtpChannel.FtpException je) {
				if (je.getStatus() == FtpChannel.FtpException.NO_SUCH_FILE) {
					mkdir_p(path);
				} else {
					err = je;
				}
			}
			if (err == null) {
				try {
					return ftp.put(path);
				} catch (IOException e) {
					err = e;
				}
			}
			throw new TransportException(
					MessageFormat.format(JGitText.get().cannotWriteObjectsPath,
							objectsPath, path, err.getMessage()),
					err);
		}

		@Override
		void writeFile(String path, byte[] data) throws IOException {
			final String lock = path + LOCK_SUFFIX;
			try {
				super.writeFile(lock, data);
				try {
					ftp.rename(lock, path);
				} catch (IOException e) {
					throw new TransportException(MessageFormat.format(
							JGitText.get().cannotWriteObjectsPath, objectsPath,
							path, e.getMessage()), e);
				}
			} catch (IOException err) {
				try {
					ftp.rm(lock);
				} catch (IOException e) {
					// Ignore deletion failure, we are already
					// failing anyway.
				}
				throw err;
			}
		}

		private void mkdir_p(String path) throws IOException {
			final int s = path.lastIndexOf('/');
			if (s <= 0)
				return;

			path = path.substring(0, s);
			Throwable err = null;
			try {
				ftp.mkdir(path);
				return;
			} catch (FileNotFoundException f) {
				mkdir_p(path);
			} catch (FtpChannel.FtpException je) {
				if (je.getStatus() == FtpChannel.FtpException.NO_SUCH_FILE) {
					mkdir_p(path);
				} else {
					err = je;
				}
			}
			if (err == null) {
				try {
					ftp.mkdir(path);
					return;
				} catch (IOException e) {
					err = e;
				}
			}
			throw new TransportException(MessageFormat.format(
						JGitText.get().cannotMkdirObjectPath, objectsPath, path,
					err.getMessage()), err);
		}

		Map<String, Ref> readAdvertisedRefs() throws TransportException {
			final TreeMap<String, Ref> avail = new TreeMap<>();
			readPackedRefs(avail);
			readRef(avail, ROOT_DIR + Constants.HEAD, Constants.HEAD);
			readLooseRefs(avail, ROOT_DIR + "refs", "refs/"); //$NON-NLS-1$ //$NON-NLS-2$
			return avail;
		}

		private void readLooseRefs(TreeMap<String, Ref> avail, String dir,
				String prefix) throws TransportException {
			final Collection<FtpChannel.DirEntry> list;
			try {
				list = ftp.ls(dir);
			} catch (IOException e) {
				throw new TransportException(MessageFormat.format(
						JGitText.get().cannotListObjectsPath, objectsPath, dir,
						e.getMessage()), e);
			}

			for (FtpChannel.DirEntry ent : list) {
				String n = ent.getFilename();
				if (".".equals(n) || "..".equals(n)) //$NON-NLS-1$ //$NON-NLS-2$
					continue;

				String nPath = dir + "/" + n; //$NON-NLS-1$
				if (ent.isDirectory()) {
					readLooseRefs(avail, nPath, prefix + n + "/"); //$NON-NLS-1$
				} else {
					readRef(avail, nPath, prefix + n);
				}
			}
		}

		private Ref readRef(TreeMap<String, Ref> avail, String path,
				String name) throws TransportException {
			final String line;
			try (BufferedReader br = openReader(path)) {
				line = br.readLine();
			} catch (FileNotFoundException noRef) {
				return null;
			} catch (IOException err) {
				throw new TransportException(MessageFormat.format(
						JGitText.get().cannotReadObjectsPath, objectsPath, path,
						err.getMessage()), err);
			}

			if (line == null) {
				throw new TransportException(
						MessageFormat.format(JGitText.get().emptyRef, name));
			}
			if (line.startsWith("ref: ")) { //$NON-NLS-1$
				final String target = line.substring("ref: ".length()); //$NON-NLS-1$
				Ref r = avail.get(target);
				if (r == null)
					r = readRef(avail, ROOT_DIR + target, target);
				if (r == null)
					r = new ObjectIdRef.Unpeeled(Ref.Storage.NEW, target, null);
				r = new SymbolicRef(name, r);
				avail.put(r.getName(), r);
				return r;
			}

			if (ObjectId.isId(line)) {
				final Ref r = new ObjectIdRef.Unpeeled(loose(avail.get(name)),
						name, ObjectId.fromString(line));
				avail.put(r.getName(), r);
				return r;
			}

			throw new TransportException(
					MessageFormat.format(JGitText.get().badRef, name, line));
		}

		private Storage loose(Ref r) {
			if (r != null && r.getStorage() == Storage.PACKED) {
				return Storage.LOOSE_PACKED;
			}
			return Storage.LOOSE;
		}

		@Override
		void close() {
			if (ftp != null) {
				try {
					if (ftp.isConnected()) {
						ftp.disconnect();
					}
				} finally {
					ftp = null;
				}
			}
		}
	}
}