| // 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 com.google.gerrit.server.project.ProjectCache.illegalState; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| |
| 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.ArchiveFormatInternal; |
| 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 = "--compression-level", |
| usage = |
| "Controls compression for different formats. The value is in [0-9] with 0 for fast levels" |
| + " with medium compressions, and 9 for the highest compression. Note that higher" |
| + " compressions require more memory.") |
| private int compressionLevel = -1; |
| |
| @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 (PacketLineIn.isEnd(s)) { |
| 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(); |
| |
| ArchiveFormatInternal 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 (Exception e) { |
| // 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(e.getMessage().getBytes(UTF_8)); |
| sidebandError.flush(); |
| } |
| throw e; |
| } finally { |
| // In any case, cleanly close the packetOut channel |
| packetOut.end(); |
| } |
| } |
| |
| private Map<String, Object> getFormatOptions(ArchiveFormatInternal f) { |
| if (options.compressionLevel != -1) { |
| return ImmutableMap.of("compression-level", options.compressionLevel); |
| } |
| if (f == ArchiveFormatInternal.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.of("level", Integer.valueOf(value)); |
| } |
| } |
| return Collections.emptyMap(); |
| } |
| |
| private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException { |
| ProjectState projectState = |
| projectCache.get(projectName).orElseThrow(illegalState(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); |
| } |
| } |
| } |
| } |