diff options
Diffstat (limited to 'java/com/google/gerrit/sshd/commands/UploadArchive.java')
-rw-r--r-- | java/com/google/gerrit/sshd/commands/UploadArchive.java | 266 |
1 files changed, 266 insertions, 0 deletions
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java new file mode 100644 index 0000000000..ac914a5268 --- /dev/null +++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java @@ -0,0 +1,266 @@ +// 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); + } + } + } +} |