// Copyright (C) 2014 The Android Open Source Project // // Licensed 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 com.google.gerrit.sshd.commands; import static java.nio.charset.StandardCharsets.UTF_8; import com.google.common.collect.ImmutableMap; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.reviewdb.server.ReviewDb; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.change.AllowedFormats; import com.google.gerrit.server.change.ArchiveFormat; import com.google.gerrit.server.permissions.PermissionBackend; import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.gerrit.server.permissions.ProjectPermission; import com.google.gerrit.sshd.AbstractGitCommand; import com.google.inject.Inject; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import org.eclipse.jgit.api.ArchiveCommand; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.transport.PacketLineIn; import org.eclipse.jgit.transport.PacketLineOut; import org.eclipse.jgit.transport.SideBandOutputStream; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.CmdLineException; import org.kohsuke.args4j.CmdLineParser; import org.kohsuke.args4j.Option; /** Allows getting archives for Git repositories over SSH using the Git upload-archive protocol. */ public class UploadArchive extends AbstractGitCommand { /** * Options for parsing Git commands. * * <p>These options are not passed on command line, but received through input stream in pkt-line * format. */ static class Options { @Option( name = "-f", aliases = {"--format"}, usage = "Format of the" + " resulting archive: tar or zip... If this option is not given, and" + " the output file is specified, the format is inferred from the" + " filename if possible (e.g. writing to \"foo.zip\" makes the output" + " to be in the zip format). Otherwise the output format is tar." ) private String format = "tar"; @Option(name = "--prefix", usage = "Prepend <prefix>/ to each filename in the archive.") private String prefix; @Option(name = "-0", usage = "Store the files instead of deflating them.") private boolean level0; @Option(name = "-1") private boolean level1; @Option(name = "-2") private boolean level2; @Option(name = "-3") private boolean level3; @Option(name = "-4") private boolean level4; @Option(name = "-5") private boolean level5; @Option(name = "-6") private boolean level6; @Option(name = "-7") private boolean level7; @Option(name = "-8") private boolean level8; @Option( name = "-9", usage = "Highest and slowest compression level. You " + "can specify any number from 1 to 9 to adjust compression speed and " + "ratio." ) private boolean level9; @Argument(index = 0, required = true, usage = "The tree or commit to produce an archive for.") private String treeIsh = "master"; @Argument( index = 1, multiValued = true, usage = "Without an optional path parameter, all files and subdirectories of " + "the current working directory are included in the archive. If one " + "or more paths are specified, only these are included." ) private List<String> path; } @Inject private PermissionBackend permissionBackend; @Inject private IdentifiedUser user; @Inject private AllowedFormats allowedFormats; @Inject private ReviewDb db; private Options options = new Options(); /** * Read and parse arguments from input stream. This method gets the arguments from input stream, * in Pkt-line format, then parses them to fill the options object. */ protected void readArguments() throws IOException, Failure { String argCmd = "argument "; List<String> args = new ArrayList<>(); // Read arguments in Pkt-Line format PacketLineIn packetIn = new PacketLineIn(in); for (; ; ) { String s = packetIn.readString(); if (s == PacketLineIn.END) { break; } if (!s.startsWith(argCmd)) { throw new Failure(1, "fatal: 'argument' token or flush expected"); } String[] parts = s.substring(argCmd.length()).split("=", 2); for (String p : parts) { args.add(p); } } try { // Parse them into the 'options' field CmdLineParser parser = new CmdLineParser(options); parser.parseArgument(args); if (options.path == null || Arrays.asList(".").equals(options.path)) { options.path = Collections.emptyList(); } } catch (CmdLineException e) { throw new Failure(2, "fatal: unable to parse arguments, " + e); } } @Override protected void runImpl() throws IOException, PermissionBackendException, Failure { PacketLineOut packetOut = new PacketLineOut(out); packetOut.setFlushOnEnd(true); packetOut.writeString("ACK"); packetOut.end(); try { // Parse Git arguments readArguments(); ArchiveFormat f = allowedFormats.getExtensions().get("." + options.format); if (f == null) { throw new Failure(3, "fatal: upload-archive not permitted"); } // Find out the object to get from the specified reference and paths ObjectId treeId = repo.resolve(options.treeIsh); if (treeId == null) { throw new Failure(4, "fatal: reference not found"); } // Verify the user has permissions to read the specified tree. if (!canRead(treeId)) { throw new Failure(5, "fatal: cannot perform upload-archive operation"); } // The archive is sent in DATA sideband channel try (SideBandOutputStream sidebandOut = new SideBandOutputStream( SideBandOutputStream.CH_DATA, SideBandOutputStream.MAX_BUF, out)) { new ArchiveCommand(repo) .setFormat(f.name()) .setFormatOptions(getFormatOptions(f)) .setTree(treeId) .setPaths(options.path.toArray(new String[0])) .setPrefix(options.prefix) .setOutputStream(sidebandOut) .call(); sidebandOut.flush(); } catch (GitAPIException e) { throw new Failure(7, "fatal: git api exception, " + e); } } catch (Failure f) { // Report the error in ERROR sideband channel try (SideBandOutputStream sidebandError = new SideBandOutputStream( SideBandOutputStream.CH_ERROR, SideBandOutputStream.MAX_BUF, out)) { sidebandError.write(f.getMessage().getBytes(UTF_8)); sidebandError.flush(); } throw f; } finally { // In any case, cleanly close the packetOut channel packetOut.end(); } } private Map<String, Object> getFormatOptions(ArchiveFormat f) { if (f == ArchiveFormat.ZIP) { int value = Arrays.asList( options.level0, options.level1, options.level2, options.level3, options.level4, options.level5, options.level6, options.level7, options.level8, options.level9) .indexOf(true); if (value >= 0) { return ImmutableMap.<String, Object>of("level", Integer.valueOf(value)); } } return Collections.emptyMap(); } private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException { try { permissionBackend.user(user).project(project.getNameKey()).check(ProjectPermission.READ); return true; } catch (AuthException e) { // Check reachability of the specific revision. try (RevWalk rw = new RevWalk(repo)) { RevCommit commit = rw.parseCommit(revId); return projectControl.canReadCommit(db, repo, commit); } } } }