| // 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); | |
| } | |
| } | |
| } | |
| } |