diff options
Diffstat (limited to 'java/com/google/gerrit/server/change/FileContentUtil.java')
-rw-r--r-- | java/com/google/gerrit/server/change/FileContentUtil.java | 319 |
1 files changed, 319 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java new file mode 100644 index 0000000000..a806f94653 --- /dev/null +++ b/java/com/google/gerrit/server/change/FileContentUtil.java @@ -0,0 +1,319 @@ +// Copyright (C) 2013 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.server.change; + +import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; + +import com.google.common.base.Strings; +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.common.data.PatchScript.FileMode; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.BinaryResult; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.reviewdb.client.Patch; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.mime.FileTypeRegistry; +import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.util.time.TimeUtil; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import eu.medsea.mimeutil.MimeType; +import java.io.IOException; +import java.io.OutputStream; +import java.security.SecureRandom; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.eclipse.jgit.errors.LargeObjectException; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.util.NB; + +@Singleton +public class FileContentUtil { + public static final String TEXT_X_GERRIT_COMMIT_MESSAGE = "text/x-gerrit-commit-message"; + public static final String TEXT_X_GERRIT_MERGE_LIST = "text/x-gerrit-merge-list"; + private static final String X_GIT_SYMLINK = "x-git/symlink"; + private static final String X_GIT_GITLINK = "x-git/gitlink"; + private static final int MAX_SIZE = 5 << 20; + private static final String ZIP_TYPE = "application/zip"; + private static final SecureRandom rng = new SecureRandom(); + + private final GitRepositoryManager repoManager; + private final FileTypeRegistry registry; + + @Inject + FileContentUtil(GitRepositoryManager repoManager, FileTypeRegistry ftr) { + this.repoManager = repoManager; + this.registry = ftr; + } + + /** + * Get the content of a file at a specific commit or one of it's parent commits. + * + * @param project A {@code Project} that this request refers to. + * @param revstr An {@code ObjectId} specifying the commit. + * @param path A string specifying the filepath. + * @param parent A 1-based parent index to get the content from instead. Null if the content + * should be obtained from {@code revstr} instead. + * @return Content of the file as {@code BinaryResult}. + * @throws ResourceNotFoundException + * @throws IOException + */ + public BinaryResult getContent( + ProjectState project, ObjectId revstr, String path, @Nullable Integer parent) + throws BadRequestException, ResourceNotFoundException, IOException { + try (Repository repo = openRepository(project); + RevWalk rw = new RevWalk(repo)) { + if (parent != null) { + RevCommit revCommit = rw.parseCommit(revstr); + if (revCommit == null) { + throw new ResourceNotFoundException("commit not found"); + } + if (parent > revCommit.getParentCount()) { + throw new BadRequestException("invalid parent"); + } + revstr = rw.parseCommit(revstr).getParent(Integer.max(0, parent - 1)).toObjectId(); + } + return getContent(repo, project, revstr, path); + } + } + + public BinaryResult getContent( + Repository repo, ProjectState project, ObjectId revstr, String path) + throws IOException, ResourceNotFoundException { + try (RevWalk rw = new RevWalk(repo)) { + RevCommit commit = rw.parseCommit(revstr); + try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) { + if (tw == null) { + throw new ResourceNotFoundException(); + } + + org.eclipse.jgit.lib.FileMode mode = tw.getFileMode(0); + ObjectId id = tw.getObjectId(0); + if (mode == org.eclipse.jgit.lib.FileMode.GITLINK) { + return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64(); + } + + ObjectLoader obj = repo.open(id, OBJ_BLOB); + byte[] raw; + try { + raw = obj.getCachedBytes(MAX_SIZE); + } catch (LargeObjectException e) { + raw = null; + } + + String type; + if (mode == org.eclipse.jgit.lib.FileMode.SYMLINK) { + type = X_GIT_SYMLINK; + } else { + type = registry.getMimeType(path, raw).toString(); + type = resolveContentType(project, path, FileMode.FILE, type); + } + + return asBinaryResult(raw, obj).setContentType(type).base64(); + } + } + } + + private static BinaryResult asBinaryResult(byte[] raw, ObjectLoader obj) { + if (raw != null) { + return BinaryResult.create(raw); + } + BinaryResult result = + new BinaryResult() { + @Override + public void writeTo(OutputStream os) throws IOException { + obj.copyTo(os); + } + }; + result.setContentLength(obj.getSize()); + return result; + } + + public BinaryResult downloadContent( + ProjectState project, ObjectId revstr, String path, @Nullable Integer parent) + throws ResourceNotFoundException, IOException { + try (Repository repo = openRepository(project); + RevWalk rw = new RevWalk(repo)) { + String suffix = "new"; + RevCommit commit = rw.parseCommit(revstr); + if (parent != null && parent > 0) { + if (commit.getParentCount() == 1) { + suffix = "old"; + } else { + suffix = "old" + parent; + } + commit = rw.parseCommit(commit.getParent(parent - 1)); + } + try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) { + if (tw == null) { + throw new ResourceNotFoundException(); + } + + int mode = tw.getFileMode(0).getObjectType(); + if (mode != Constants.OBJ_BLOB) { + throw new ResourceNotFoundException(); + } + + ObjectId id = tw.getObjectId(0); + ObjectLoader obj = repo.open(id, OBJ_BLOB); + byte[] raw; + try { + raw = obj.getCachedBytes(MAX_SIZE); + } catch (LargeObjectException e) { + raw = null; + } + + MimeType contentType = registry.getMimeType(path, raw); + return registry.isSafeInline(contentType) + ? wrapBlob(path, obj, raw, contentType, suffix) + : zipBlob(path, obj, commit, suffix); + } + } + } + + private BinaryResult wrapBlob( + String path, + final ObjectLoader obj, + byte[] raw, + MimeType contentType, + @Nullable String suffix) { + return asBinaryResult(raw, obj) + .setContentType(contentType.toString()) + .setAttachmentName(safeFileName(path, suffix)); + } + + @SuppressWarnings("resource") + private BinaryResult zipBlob( + final String path, ObjectLoader obj, RevCommit commit, @Nullable final String suffix) { + final String commitName = commit.getName(); + final long when = commit.getCommitTime() * 1000L; + return new BinaryResult() { + @Override + public void writeTo(OutputStream os) throws IOException { + try (ZipOutputStream zipOut = new ZipOutputStream(os)) { + String decoration = randSuffix(); + if (!Strings.isNullOrEmpty(suffix)) { + decoration = suffix + '-' + decoration; + } + ZipEntry e = new ZipEntry(safeFileName(path, decoration)); + e.setComment(commitName + ":" + path); + e.setSize(obj.getSize()); + e.setTime(when); + zipOut.putNextEntry(e); + obj.copyTo(zipOut); + zipOut.closeEntry(); + } + } + }.setContentType(ZIP_TYPE).setAttachmentName(safeFileName(path, suffix) + ".zip").disableGzip(); + } + + private static String safeFileName(String fileName, @Nullable String suffix) { + // Convert a file path (e.g. "src/Init.c") to a safe file name with + // no meta-characters that might be unsafe on any given platform. + // + int slash = fileName.lastIndexOf('/'); + if (slash >= 0) { + fileName = fileName.substring(slash + 1); + } + + StringBuilder r = new StringBuilder(fileName.length()); + for (int i = 0; i < fileName.length(); i++) { + final char c = fileName.charAt(i); + if (c == '_' || c == '-' || c == '.' || c == '@') { + r.append(c); + } else if ('0' <= c && c <= '9') { + r.append(c); + } else if ('A' <= c && c <= 'Z') { + r.append(c); + } else if ('a' <= c && c <= 'z') { + r.append(c); + } else if (c == ' ' || c == '\n' || c == '\r' || c == '\t') { + r.append('-'); + } else { + r.append('_'); + } + } + fileName = r.toString(); + + int ext = fileName.lastIndexOf('.'); + if (suffix == null) { + return fileName; + } else if (ext <= 0) { + return fileName + "_" + suffix; + } else { + return fileName.substring(0, ext) + "_" + suffix + fileName.substring(ext); + } + } + + private static String randSuffix() { + // Produce a random suffix that is difficult (or nearly impossible) + // for an attacker to guess in advance. This reduces the risk that + // an attacker could upload a *.class file and have us send a ZIP + // that can be invoked through an applet tag in the victim's browser. + // + Hasher h = Hashing.murmur3_128().newHasher(); + byte[] buf = new byte[8]; + + NB.encodeInt64(buf, 0, TimeUtil.nowMs()); + h.putBytes(buf); + + rng.nextBytes(buf); + h.putBytes(buf); + + return h.hash().toString(); + } + + public static String resolveContentType( + ProjectState project, String path, FileMode fileMode, String mimeType) { + switch (fileMode) { + case FILE: + if (Patch.COMMIT_MSG.equals(path)) { + return TEXT_X_GERRIT_COMMIT_MESSAGE; + } + if (Patch.MERGE_LIST.equals(path)) { + return TEXT_X_GERRIT_MERGE_LIST; + } + if (project != null) { + for (ProjectState p : project.tree()) { + String t = p.getConfig().getMimeTypes().getMimeType(path); + if (t != null) { + return t; + } + } + } + return mimeType; + case GITLINK: + return X_GIT_GITLINK; + case SYMLINK: + return X_GIT_SYMLINK; + default: + throw new IllegalStateException("file mode: " + fileMode); + } + } + + private Repository openRepository(ProjectState project) + throws RepositoryNotFoundException, IOException { + return repoManager.openRepository(project.getNameKey()); + } +} |