| // 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 static java.util.Objects.requireNonNull; | 
 |  | 
 | import com.google.common.base.Splitter; | 
 | import com.google.common.collect.ImmutableMap; | 
 | import com.google.gerrit.extensions.restapi.AuthException; | 
 | 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.server.project.ProjectCache; | 
 | import com.google.gerrit.server.project.ProjectState; | 
 | import com.google.gerrit.server.restapi.change.AllowedFormats; | 
 | import com.google.gerrit.server.restapi.project.CommitsCollection; | 
 | 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; | 
 | import org.kohsuke.args4j.ParserProperties; | 
 |  | 
 | /** 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 CommitsCollection commits; | 
 |   @Inject private AllowedFormats allowedFormats; | 
 |   @Inject private ProjectCache projectCache; | 
 |   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, got " + s); | 
 |       } | 
 |       for (String p : Splitter.on('=').limit(2).split(s.substring(argCmd.length()))) { | 
 |         args.add(p); | 
 |       } | 
 |     } | 
 |  | 
 |     try { | 
 |       // Parse them into the 'options' field | 
 |       CmdLineParser parser = | 
 |           new CmdLineParser(options, ParserProperties.defaults().withAtSyntax(false)); | 
 |       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 for format " + options.format); | 
 |       } | 
 |  | 
 |       // 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: " + options.treeIsh); | 
 |       } | 
 |  | 
 |       // Verify the user has permissions to read the specified tree. | 
 |       if (!canRead(treeId)) { | 
 |         throw new Failure(5, "fatal: no permission to read tree" + options.treeIsh); | 
 |       } | 
 |  | 
 |       // 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 (Throwable t) { | 
 |       // Report the error in ERROR sideband channel. Catch Throwable too so we can also catch | 
 |       // NoClassDefFound. | 
 |       try (SideBandOutputStream sidebandError = | 
 |           new SideBandOutputStream( | 
 |               SideBandOutputStream.CH_ERROR, SideBandOutputStream.MAX_BUF, out)) { | 
 |         sidebandError.write(t.getMessage().getBytes(UTF_8)); | 
 |         sidebandError.flush(); | 
 |       } | 
 |       throw t; | 
 |     } 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 { | 
 |     ProjectState projectState = projectCache.get(projectName); | 
 |     requireNonNull(projectState, () -> String.format("Failed to load project %s", projectName)); | 
 |  | 
 |     if (!projectState.statePermitsRead()) { | 
 |       return false; | 
 |     } | 
 |  | 
 |     try { | 
 |       permissionBackend.user(user).project(projectName).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 commits.canRead(projectState, repo, commit); | 
 |       } | 
 |     } | 
 |   } | 
 | } |