blob: c1f4a7b230d6f0e2828d90dde2bf48085c820135 [file] [log] [blame]
// 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);
}
}
}
}