268 lines
9.1 KiB
Java
268 lines
9.1 KiB
Java
// 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.common.base.Preconditions.checkNotNull;
|
|
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.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;
|
|
|
|
/** 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);
|
|
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);
|
|
checkNotNull(projectState, "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);
|
|
}
|
|
}
|
|
}
|
|
}
|