Merge changes I9639f01e,I34fbdd3f,Id85130d1 * changes: MimeTypes: Simple file extension to MIME type mapping Compress some relative links in Markdown Refactor MarkdownToHtml with Builder pattern
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/MimeTypes.java b/gitiles-servlet/src/main/java/com/google/gitiles/MimeTypes.java new file mode 100644 index 0000000..ed219ce --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/MimeTypes.java
@@ -0,0 +1,56 @@ +// Copyright (C) 2016 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.gitiles; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.Properties; + +public class MimeTypes { + public static final String ANY = "application/octet-stream"; + private static final ImmutableMap<String, String> TYPES; + + static { + Properties p = new Properties(); + try (InputStream in = MimeTypes.class.getResourceAsStream("mime-types.properties")) { + p.load(in); + } catch (IOException e) { + throw new RuntimeException("Cannot load mime-types.properties", e); + } + + ImmutableMap.Builder<String, String> m = ImmutableMap.builder(); + for (Map.Entry<Object, Object> e : p.entrySet()) { + m.put(((String) e.getKey()).toLowerCase(), (String) e.getValue()); + } + TYPES = m.build(); + } + + public static String getMimeType(String path) { + int d = path.lastIndexOf('.'); + if (d == -1) { + return ANY; + } + + String ext = path.substring(d + 1); + String type = TYPES.get(ext.toLowerCase()); + return MoreObjects.firstNonNull(type, ANY); + } + + private MimeTypes() {} +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java index da9138e..30470f8 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -495,7 +495,7 @@ "breadcrumbs", view.getBreadcrumbs(wr.hasSingleTree), "type", FileType.TREE.toString(), "data", - new TreeSoyData(wr.getObjectReader(), view, cfg, wr.root) + new TreeSoyData(wr.getObjectReader(), view, cfg, wr.root, req.getRequestURI()) .setArchiveFormat(getArchiveFormat(getAccess(req))) .toSoyData(wr.id, wr.tw))); }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java index 9a05f35..3dcf1ea 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
@@ -13,24 +13,20 @@ // limitations under the License. package com.google.gitiles; - import com.google.gitiles.doc.GitilesMarkdown; -import com.google.gitiles.doc.ImageLoader; +import com.google.gitiles.doc.MarkdownConfig; import com.google.gitiles.doc.MarkdownToHtml; import com.google.template.soy.data.SanitizedContent; -import org.commonmark.node.Node; import org.eclipse.jgit.errors.CorruptObjectException; import org.eclipse.jgit.errors.IncorrectObjectTypeException; import org.eclipse.jgit.errors.MissingObjectException; -import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.treewalk.TreeWalk; -import org.eclipse.jgit.util.RawParseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,25 +37,30 @@ private final ObjectReader reader; private final GitilesView view; - private final Config cfg; + private final MarkdownConfig config; private final RevTree rootTree; - private final boolean render; + private final String requestUri; private String readmePath; private ObjectId readmeId; - ReadmeHelper(ObjectReader reader, GitilesView view, Config cfg, RevTree rootTree) { + ReadmeHelper( + ObjectReader reader, + GitilesView view, + MarkdownConfig config, + RevTree rootTree, + String requestUri) { this.reader = reader; this.view = view; - this.cfg = cfg; + this.config = config; this.rootTree = rootTree; - render = cfg.getBoolean("markdown", "render", true); + this.requestUri = requestUri; } void scanTree(RevTree tree) throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException, IOException { - if (render) { + if (config.render) { TreeWalk tw = new TreeWalk(reader); tw.setRecursive(false); tw.addTree(tree); @@ -70,7 +71,7 @@ } void considerEntry(TreeWalk tw) { - if (render + if (config.render && FileMode.REGULAR_FILE.equals(tw.getRawMode(0)) && isReadmeFile(tw.getNameString())) { readmePath = tw.getPathString(); @@ -88,21 +89,16 @@ SanitizedContent render() { try { - int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20); - byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(inputLimit); - String md = RawParseUtils.decode(raw); - Node root = GitilesMarkdown.parse(md); - if (root == null) { - return null; - } - - int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10); - ImageLoader img = null; - if (imageLimit > 0) { - img = new ImageLoader(reader, view, rootTree, readmePath, imageLimit); - } - - return new MarkdownToHtml(view, cfg, readmePath).setImageLoader(img).toSoyHtml(root); + byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(config.inputLimit); + return MarkdownToHtml.builder() + .setConfig(config) + .setGitilesView(view) + .setRequestUri(requestUri) + .setFilePath(readmePath) + .setReader(reader) + .setRootTree(rootTree) + .build() + .toSoyHtml(GitilesMarkdown.parse(raw)); } catch (RuntimeException | IOException err) { log.error( String.format(
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java index f4d9327..fee1507 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -23,6 +23,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.google.gitiles.DateFormatter.Format; +import com.google.gitiles.doc.MarkdownConfig; import com.google.gson.reflect.TypeToken; import com.google.template.soy.data.SanitizedContent; @@ -98,7 +99,7 @@ if (headId != null) { RevObject head = walk.parseAny(headId); int limit = LOG_LIMIT; - Map<String, Object> readme = renderReadme(walk, view, access.getConfig(), head); + Map<String, Object> readme = renderReadme(req, walk, view, access.getConfig(), head); if (readme != null) { data.putAll(readme); limit = LOG_WITH_README_LIMIT; @@ -156,7 +157,8 @@ } private static Map<String, Object> renderReadme( - RevWalk walk, GitilesView view, Config cfg, RevObject head) throws IOException { + HttpServletRequest req, RevWalk walk, GitilesView view, Config cfg, RevObject head) + throws IOException { RevTree rootTree; try { rootTree = walk.parseTree(head); @@ -168,8 +170,9 @@ new ReadmeHelper( walk.getObjectReader(), GitilesView.path().copyFrom(view).setRevision(Revision.HEAD).setPathPart("/").build(), - cfg, - rootTree); + MarkdownConfig.get(cfg), + rootTree, + req.getRequestURI()); readme.scanTree(rootTree); if (readme.isPresent()) { SanitizedContent html = readme.render();
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java index 9c7fdb0..cc80490 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
@@ -105,7 +105,9 @@ break; case OBJ_TREE: Map<String, Object> tree = - new TreeSoyData(walk.getObjectReader(), view, cfg, (RevTree) obj).toSoyData(obj); + new TreeSoyData( + walk.getObjectReader(), view, cfg, (RevTree) obj, req.getRequestURI()) + .toSoyData(obj); soyObjects.add(ImmutableMap.of("type", Constants.TYPE_TREE, "data", tree)); hasReadme = tree.containsKey("readmeHtml"); break;
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java index f1366ad..43fddfb 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -22,6 +22,7 @@ import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.gitiles.PathServlet.FileType; +import com.google.gitiles.doc.MarkdownConfig; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.Config; @@ -72,13 +73,16 @@ private final GitilesView view; private final Config cfg; private final RevTree rootTree; + private final String requestUri; private ArchiveFormat archiveFormat; - public TreeSoyData(ObjectReader reader, GitilesView view, Config cfg, RevTree rootTree) { + public TreeSoyData( + ObjectReader reader, GitilesView view, Config cfg, RevTree rootTree, String requestUri) { this.reader = reader; this.view = view; this.cfg = cfg; this.rootTree = rootTree; + this.requestUri = requestUri; } public TreeSoyData setArchiveFormat(ArchiveFormat archiveFormat) { @@ -88,7 +92,8 @@ public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw) throws MissingObjectException, IOException { - ReadmeHelper readme = new ReadmeHelper(reader, view, cfg, rootTree); + ReadmeHelper readme = + new ReadmeHelper(reader, view, MarkdownConfig.get(cfg), rootTree, requestUri); List<Object> entries = Lists.newArrayList(); GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view); while (tw.next()) {
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java index 6561ada..822f2c4 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
@@ -37,26 +37,28 @@ import org.commonmark.node.Node; import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.LargeObjectException; import org.eclipse.jgit.http.server.ServletUtils; -import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.lib.ObjectLoader; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; -import org.eclipse.jgit.util.RawParseUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.HashMap; import java.util.Map; +import javax.annotation.Nullable; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class DocServlet extends BaseServlet { + private static final Logger log = LoggerFactory.getLogger(DocServlet.class); private static final long serialVersionUID = 1L; private static final String INDEX_MD = "index.md"; @@ -75,8 +77,8 @@ @Override protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException { - Config cfg = getAccess(req).getConfig(); - if (!cfg.getBoolean("markdown", "render", true)) { + MarkdownConfig cfg = MarkdownConfig.get(getAccess(req).getConfig()); + if (!cfg.render) { res.setStatus(SC_NOT_FOUND); return; } @@ -84,6 +86,7 @@ GitilesView view = ViewFilter.getView(req); Repository repo = ServletUtils.getRepository(req); try (RevWalk rw = new RevWalk(repo)) { + ObjectReader reader = rw.getObjectReader(); String path = view.getPathPart(); RevTree root; try { @@ -93,53 +96,51 @@ return; } - SourceFile srcmd = findFile(rw, root, path); + MarkdownFile srcmd = findFile(rw, root, path); if (srcmd == null) { res.setStatus(SC_NOT_FOUND); return; } - SourceFile navmd = findFile(rw, root, NAVBAR_MD); - String reqEtag = req.getHeader(HttpHeaders.IF_NONE_MATCH); + MarkdownFile navmd = findFile(rw, root, NAVBAR_MD); String curEtag = etag(srcmd, navmd); - if (reqEtag != null && reqEtag.equals(curEtag)) { + if (etagMatch(req, curEtag)) { res.setStatus(SC_NOT_MODIFIED); return; } view = view.toBuilder().setPathPart(srcmd.path).build(); - int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20); - Node doc = GitilesMarkdown.parse(srcmd.read(rw.getObjectReader(), inputLimit)); - if (doc == null) { - res.sendRedirect(GitilesView.show().copyFrom(view).toUrl()); + try { + srcmd.read(reader, cfg); + if (navmd != null) { + navmd.read(reader, cfg); + } + } catch (LargeObjectException.ExceedsLimit errBig) { + fileTooBig(res, view, errBig); + return; + } catch (IOException err) { + readError(res, view, err); return; } - String navPath = null; - String navMarkdown = null; - Node nav = null; - if (navmd != null) { - navPath = navmd.path; - navMarkdown = navmd.read(rw.getObjectReader(), inputLimit); - nav = GitilesMarkdown.parse(navMarkdown); - if (nav == null) { - res.setStatus(SC_INTERNAL_SERVER_ERROR); - return; - } - } - - int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10); - ImageLoader img = null; - if (imageLimit > 0) { - img = new ImageLoader(rw.getObjectReader(), view, root, srcmd.path, imageLimit); - } - + MarkdownToHtml.Builder fmt = + MarkdownToHtml.builder() + .setConfig(cfg) + .setGitilesView(view) + .setRequestUri(req.getRequestURI()) + .setReader(reader) + .setRootTree(root); res.setHeader(HttpHeaders.ETAG, curEtag); - showDoc(req, res, view, cfg, img, navPath, navMarkdown, nav, srcmd.path, doc); + showDoc(req, res, view, cfg, fmt, navmd, srcmd); } } - private String etag(SourceFile srcmd, SourceFile navmd) { + private static boolean etagMatch(HttpServletRequest req, String etag) { + String reqEtag = req.getHeader(HttpHeaders.IF_NONE_MATCH); + return reqEtag != null && reqEtag.equals(etag); + } + + private String etag(MarkdownFile srcmd, @Nullable MarkdownFile navmd) { byte[] b = new byte[Constants.OBJECT_ID_LENGTH]; Hasher h = Hashing.sha1().newHasher(); h.putInt(ETAG_GEN); @@ -169,32 +170,30 @@ HttpServletRequest req, HttpServletResponse res, GitilesView view, - Config cfg, - ImageLoader img, - String navPath, - String navMarkdown, - Node nav, - String docPath, - Node doc) + MarkdownConfig cfg, + MarkdownToHtml.Builder fmt, + MarkdownFile navFile, + MarkdownFile srcFile) throws IOException { Map<String, Object> data = new HashMap<>(); + Navbar navbar = new Navbar(); + if (navFile != null) { + navbar.setFormatter(fmt.setFilePath(navFile.path).build()); + navbar.setMarkdown(navFile.content); + } + data.putAll(navbar.toSoyData()); - MarkdownToHtml navHtml = new MarkdownToHtml(view, cfg, navPath); - data.putAll(Navbar.bannerSoyData(img, navHtml, navMarkdown, nav)); - data.put("navbarHtml", navHtml.toSoyHtml(nav)); - - data.put("pageTitle", MoreObjects.firstNonNull(MarkdownUtil.getTitle(doc), view.getPathPart())); + Node doc = GitilesMarkdown.parse(srcFile.content); + data.put("pageTitle", pageTitle(doc, srcFile)); if (view.getType() != GitilesView.Type.ROOTED_DOC) { data.put("sourceUrl", GitilesView.show().copyFrom(view).toUrl()); data.put("logUrl", GitilesView.log().copyFrom(view).toUrl()); data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl()); } - data.put("bodyHtml", new MarkdownToHtml(view, cfg, docPath).setImageLoader(img).toSoyHtml(doc)); - - String analyticsId = cfg.getString("google", null, "analyticsId"); - if (!Strings.isNullOrEmpty(analyticsId)) { - data.put("analyticsId", analyticsId); + if (cfg.analyticsId != null) { + data.put("analyticsId", cfg.analyticsId); } + data.put("bodyHtml", fmt.setFilePath(srcFile.path).build().toSoyHtml(doc)); String page = renderer.render(SOY_TEMPLATE, data); byte[] raw = page.getBytes(UTF_8); @@ -211,7 +210,13 @@ res.getOutputStream().write(raw); } - private static SourceFile findFile(RevWalk rw, RevTree root, String path) throws IOException { + private static String pageTitle(Node doc, MarkdownFile srcFile) { + String title = MarkdownUtil.getTitle(doc); + return MoreObjects.firstNonNull(title, srcFile.path); + } + + @Nullable + private static MarkdownFile findFile(RevWalk rw, RevTree root, String path) throws IOException { if (Strings.isNullOrEmpty(path)) { path = INDEX_MD; } @@ -232,7 +237,7 @@ if (!path.endsWith(".md")) { return null; } - return new SourceFile(path, tw.getObjectId(0)); + return new MarkdownFile(path, tw.getObjectId(0)); } return null; } @@ -247,19 +252,48 @@ return false; } - private static class SourceFile { + private static void fileTooBig( + HttpServletResponse res, GitilesView view, LargeObjectException.ExceedsLimit errBig) + throws IOException { + if (view.getType() == GitilesView.Type.ROOTED_DOC) { + log.error( + String.format( + "markdown too large: %s/%s %s %s: %s", + view.getHostName(), + view.getRepositoryName(), + view.getRevision(), + view.getPathPart(), + errBig.getMessage())); + res.setStatus(SC_INTERNAL_SERVER_ERROR); + } else { + res.sendRedirect(GitilesView.show().copyFrom(view).toUrl()); + } + } + + private static void readError(HttpServletResponse res, GitilesView view, IOException err) { + log.error( + String.format( + "cannot load markdown %s/%s %s %s", + view.getHostName(), + view.getRepositoryName(), + view.getRevision(), + view.getPathPart()), + err); + res.setStatus(SC_INTERNAL_SERVER_ERROR); + } + + private static class MarkdownFile { final String path; final ObjectId id; + byte[] content; - SourceFile(String path, ObjectId id) { + MarkdownFile(String path, ObjectId id) { this.path = path; this.id = id; } - String read(ObjectReader reader, int inputLimit) throws IOException { - ObjectLoader obj = reader.open(id, OBJ_BLOB); - byte[] raw = obj.getCachedBytes(inputLimit); - return RawParseUtils.decode(raw); + void read(ObjectReader reader, MarkdownConfig cfg) throws IOException { + content = reader.open(id, OBJ_BLOB).getCachedBytes(cfg.inputLimit); } } }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java index 541343b..fcd00dc 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
@@ -21,6 +21,7 @@ import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; +import org.eclipse.jgit.util.RawParseUtils; /** Parses Gitiles style CommonMark Markdown. */ public class GitilesMarkdown { @@ -40,8 +41,12 @@ TocExtension.create())) .build(); + public static Node parse(byte[] md) { + return parse(RawParseUtils.decode(md)); + } + public static Node parse(String md) { - return md != null ? PARSER.parse(md) : null; + return PARSER.parse(md); } private GitilesMarkdown() {}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java index b381616..4fca76a 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java
@@ -14,9 +14,10 @@ package com.google.gitiles.doc; -import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.io.BaseEncoding; import com.google.gitiles.GitilesView; +import com.google.gitiles.MimeTypes; import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri; import org.eclipse.jgit.errors.LargeObjectException; @@ -31,93 +32,68 @@ import java.io.IOException; +import javax.annotation.Nullable; + /** Reads an image from Git and converts to {@code data:image/*;base64,...} */ -public class ImageLoader { +class ImageLoader { private static final Logger log = LoggerFactory.getLogger(ImageLoader.class); + private static final ImmutableSet<String> ALLOWED_TYPES = + ImmutableSet.of("image/gif", "image/jpeg", "image/png"); private final ObjectReader reader; private final GitilesView view; + private final MarkdownConfig config; private final RevTree root; - private final String path; - private final int imageLimit; - public ImageLoader( - ObjectReader reader, GitilesView view, RevTree root, String path, int maxImageSize) { + ImageLoader(ObjectReader reader, GitilesView view, MarkdownConfig config, RevTree root) { this.reader = reader; this.view = view; + this.config = config; this.root = root; - this.path = path; - this.imageLimit = maxImageSize; } - String loadImage(String src) { - if (src.startsWith("/")) { - return readAndBase64Encode(src.substring(1)); + String inline(@Nullable String markdownPath, String imagePath) { + String data = inlineMaybe(markdownPath, imagePath); + if (data != null) { + return data; } - - String base = directory(); - while (src.startsWith("../")) { - int s = base.lastIndexOf('/'); - if (s == -1) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); - } - base = base.substring(0, s + 1); - src = src.substring("../".length()); - } - return readAndBase64Encode(base + src); + return FilterImageDataUri.INSTANCE.getInnocuousOutput(); } - private String directory() { - int s = path.lastIndexOf('/'); - if (s > 0) { - return path.substring(0, s + 1); + private String inlineMaybe(@Nullable String markdownPath, String imagePath) { + if (config.imageLimit <= 0) { + return null; } - return ""; - } - private String readAndBase64Encode(String path) { - String type = getMimeType(path); - if (type == null) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + String path = PathResolver.resolve(markdownPath, imagePath); + if (path == null) { + return null; + } + + String type = MimeTypes.getMimeType(path); + if (!ALLOWED_TYPES.contains(type)) { + return null; } try { TreeWalk tw = TreeWalk.forPath(reader, path, root); if (tw == null || tw.getFileMode(0) != FileMode.REGULAR_FILE) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + return null; } ObjectId id = tw.getObjectId(0); - byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(imageLimit); - if (raw.length > imageLimit) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(config.imageLimit); + if (raw.length > config.imageLimit) { + return null; } - return "data:" + type + ";base64," + BaseEncoding.base64().encode(raw); } catch (LargeObjectException.ExceedsLimit e) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); - } catch (IOException e) { + return null; + } catch (IOException err) { + String repo = view != null ? view.getRepositoryName() : "<unknown>"; log.error( - String.format( - "cannot read repo %s image %s from %s", view.getRepositoryName(), path, root.name()), - e); - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); - } - } - - private static final ImmutableMap<String, String> TYPES = - ImmutableMap.of( - "png", "image/png", - "gif", "image/gif", - "jpg", "image/jpeg", - "jpeg", "image/jpeg"); - - private static String getMimeType(String path) { - int d = path.lastIndexOf('.'); - if (d == -1) { + String.format("cannot read repo %s image %s from %s", repo, path, root.name()), err); return null; } - String ext = path.substring(d + 1); - return TYPES.get(ext.toLowerCase()); } }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java new file mode 100644 index 0000000..f229f8e --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java
@@ -0,0 +1,74 @@ +// Copyright (C) 2016 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.gitiles.doc; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; + +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Config.SectionParser; +import org.eclipse.jgit.util.StringUtils; + +public class MarkdownConfig { + public static final int IMAGE_LIMIT = 256 << 10; + + public static MarkdownConfig get(Config cfg) { + return cfg.get(CONFIG_PARSER); + } + + private static SectionParser<MarkdownConfig> CONFIG_PARSER = + new SectionParser<MarkdownConfig>() { + @Override + public MarkdownConfig parse(Config cfg) { + return new MarkdownConfig(cfg); + } + }; + + public final boolean render; + public final int inputLimit; + + final int imageLimit; + final String analyticsId; + + private final boolean allowAnyIFrame; + private final ImmutableList<String> allowIFrame; + + MarkdownConfig(Config cfg) { + render = cfg.getBoolean("markdown", "render", true); + inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20); + imageLimit = cfg.getInt("markdown", "imageLimit", IMAGE_LIMIT); + analyticsId = Strings.emptyToNull(cfg.getString("google", null, "analyticsId")); + + String[] f = cfg.getStringList("markdown", null, "allowiframe"); + allowAnyIFrame = f.length == 1 && StringUtils.toBooleanOrNull(f[0]) == Boolean.TRUE; + if (allowAnyIFrame) { + allowIFrame = ImmutableList.of(); + } else { + allowIFrame = ImmutableList.copyOf(f); + } + } + + boolean isIFrameAllowed(String src) { + if (allowAnyIFrame) { + return true; + } + for (String url : allowIFrame) { + if (url.equals(src) || (url.endsWith("/") && src.startsWith(url))) { + return true; + } + } + return false; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java index 1b88d5b..7a116c1 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -22,6 +22,7 @@ import com.google.gitiles.ThreadSafePrettifyParser; import com.google.gitiles.doc.html.HtmlBuilder; import com.google.template.soy.data.SanitizedContent; +import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri; import com.google.template.soy.shared.restricted.EscapingConventions.FilterNormalizeUri; import org.commonmark.ext.gfm.strikethrough.Strikethrough; @@ -56,13 +57,15 @@ import org.commonmark.node.Text; import org.commonmark.node.ThematicBreak; import org.commonmark.node.Visitor; -import org.eclipse.jgit.lib.Config; -import org.eclipse.jgit.util.StringUtils; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.revwalk.RevTree; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.annotation.Nullable; + import prettify.parser.Prettify; import syntaxhighlight.ParseResult; @@ -72,34 +75,77 @@ * Callers must create a new instance for each document. */ public class MarkdownToHtml implements Visitor { - private final HtmlBuilder html = new HtmlBuilder(); - private final TocFormatter toc = new TocFormatter(html, 3); - private final GitilesView view; - private final Config cfg; - private final String filePath; - private ImageLoader imageLoader; - private boolean outputNamedAnchor = true; - - /** - * Initialize a Markdown to HTML converter. - * - * @param view view used to access this Markdown on the web. Some elements of - * the view may be used to generate hyperlinks to other files, e.g. - * repository name and revision. - * @param cfg - * @param filePath actual path of the Markdown file in the Git repository. This must - * always be a file, e.g. {@code doc/README.md}. The path is used to - * resolve relative links within the repository. - */ - public MarkdownToHtml(GitilesView view, Config cfg, String filePath) { - this.view = view; - this.cfg = cfg; - this.filePath = filePath; + public static Builder builder() { + return new Builder(); } - public MarkdownToHtml setImageLoader(ImageLoader img) { - imageLoader = img; - return this; + public static class Builder { + private String requestUri; + private GitilesView view; + private MarkdownConfig config; + private String filePath; + private ObjectReader reader; + private RevTree root; + + Builder() {} + + public Builder setRequestUri(@Nullable String uri) { + requestUri = uri; + return this; + } + + public Builder setGitilesView(@Nullable GitilesView view) { + this.view = view; + return this; + } + + public Builder setConfig(@Nullable MarkdownConfig config) { + this.config = config; + return this; + } + + public Builder setFilePath(@Nullable String filePath) { + this.filePath = Strings.emptyToNull(filePath); + return this; + } + + public Builder setReader(ObjectReader reader) { + this.reader = reader; + return this; + } + + public Builder setRootTree(RevTree tree) { + this.root = tree; + return this; + } + + public MarkdownToHtml build() { + return new MarkdownToHtml(this); + } + } + + private final HtmlBuilder html = new HtmlBuilder(); + private final TocFormatter toc = new TocFormatter(html, 3); + private final String requestUri; + private final GitilesView view; + private final MarkdownConfig config; + private final String filePath; + private final ImageLoader imageLoader; + private boolean outputNamedAnchor = true; + + private MarkdownToHtml(Builder b) { + requestUri = b.requestUri; + view = b.view; + config = b.config; + filePath = b.filePath; + imageLoader = newImageLoader(b); + } + + private static ImageLoader newImageLoader(Builder b) { + if (b.reader != null && b.view != null && b.config != null && b.root != null) { + return new ImageLoader(b.reader, b.view, b.config, b.root); + } + return null; } /** Render the document AST to sanitized HTML. */ @@ -148,7 +194,8 @@ if (HtmlBuilder.isValidHttpUri(node.src) && HtmlBuilder.isValidCssDimension(node.height) && HtmlBuilder.isValidCssDimension(node.width) - && canRender(node)) { + && config != null + && config.isIFrameAllowed(node.src)) { html.open("iframe") .attribute("src", node.src) .attribute("height", node.height) @@ -160,19 +207,6 @@ } } - private boolean canRender(IframeBlock node) { - String[] ok = cfg.getStringList("markdown", null, "allowiframe"); - if (ok.length == 1 && StringUtils.toBooleanOrNull(ok[0]) == Boolean.TRUE) { - return true; - } - for (String m : ok) { - if (m.equals(node.src) || (m.endsWith("/") && node.src.startsWith(m))) { - return true; - } - } - return false; // By default do not render iframe. - } - @Override public void visit(Heading node) { outputNamedAnchor = false; @@ -341,61 +375,37 @@ target = target.substring(0, hash); } - if (target.startsWith("/")) { - return toPath(target) + anchor; + String dest = PathResolver.resolve(filePath, target); + if (dest == null || view == null) { + return FilterNormalizeUri.INSTANCE.getInnocuousOutput(); } - String dir = trimLastComponent(filePath); - while (!target.isEmpty()) { - if (target.startsWith("../") || target.equals("..")) { - if (dir.isEmpty()) { - return FilterNormalizeUri.INSTANCE.getInnocuousOutput(); - } - dir = trimLastComponent(dir); - target = target.equals("..") ? "" : target.substring(3); - } else if (target.startsWith("./")) { - target = target.substring(2); - } else if (target.equals(".")) { - target = ""; - } else { - break; - } - } - - return toPath(dir + '/' + target) + anchor; - } - - private static String trimLastComponent(String path) { - int slash = path.lastIndexOf('/'); - return slash < 0 ? "" : path.substring(0, slash); - } - - private String toPath(String path) { GitilesView.Builder b; if (view.getType() == GitilesView.Type.ROOTED_DOC) { b = GitilesView.rootedDoc(); } else { b = GitilesView.path(); } - return b.copyFrom(view).setPathPart(path).build().toUrl(); + dest = b.copyFrom(view).setPathPart(dest).build().toUrl(); + + return PathResolver.relative(requestUri, dest) + anchor; } @Override public void visit(Image node) { html.open("img") - .attribute("src", resolveImageUrl(node.getDestination())) + .attribute("src", image(node.getDestination())) .attribute("title", node.getTitle()) .attribute("alt", getInnerText(node)); } - private String resolveImageUrl(String url) { - if (imageLoader == null - || url.startsWith("https://") - || url.startsWith("http://") - || url.startsWith("data:")) { - return url; + String image(String dest) { + if (HtmlBuilder.isValidHttpUri(dest) || HtmlBuilder.isImageDataUri(dest)) { + return dest; + } else if (imageLoader != null) { + return imageLoader.inline(filePath, dest); } - return imageLoader.loadImage(url); + return FilterImageDataUri.INSTANCE.getInnocuousOutput(); } public void visit(TableBlock node) {
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java index c1ed246..5affd6c 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java
@@ -15,11 +15,12 @@ package com.google.gitiles.doc; import com.google.gitiles.doc.html.HtmlBuilder; -import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri; import com.google.template.soy.shared.restricted.Sanitizers; +import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri; import org.commonmark.node.Heading; import org.commonmark.node.Node; +import org.eclipse.jgit.util.RawParseUtils; import java.util.HashMap; import java.util.Map; @@ -30,61 +31,83 @@ private static final Pattern REF_LINK = Pattern.compile("^\\[(logo|home)\\]:\\s*(.+)$", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); - static Map<String, Object> bannerSoyData( - ImageLoader img, MarkdownToHtml toHtml, String navMarkdown, Node nav) { - Map<String, Object> data = new HashMap<>(); - data.put("siteTitle", null); - data.put("logoUrl", null); - data.put("homeUrl", null); + private MarkdownToHtml fmt; + private Node node; + private String siteTitle; + private String logoUrl; + private String homeUrl; - if (nav == null) { - return data; + Navbar() {} + + Navbar setFormatter(MarkdownToHtml html) { + this.fmt = html; + return this; + } + + Navbar setMarkdown(byte[] md) { + if (md != null && md.length > 0) { + parse(RawParseUtils.decode(md)); + } + return this; + } + + Map<String, Object> toSoyData() { + Map<String, Object> data = new HashMap<>(); + data.put("siteTitle", siteTitle); + data.put("logoUrl", logo()); + data.put("homeUrl", homeUrl != null ? fmt.href(homeUrl) : null); + data.put("navbarHtml", node != null ? fmt.toSoyHtml(node) : null); + return data; + } + + private Object logo() { + if (logoUrl == null) { + return null; } - for (Node c = nav.getFirstChild(); c != null; c = c.getNext()) { + String url = fmt.image(logoUrl); + if (HtmlBuilder.isValidHttpUri(url)) { + return url; + } else if (HtmlBuilder.isImageDataUri(url)) { + return Sanitizers.filterImageDataUri(url); + } else { + return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + } + } + + private void parse(String markdown) { + node = GitilesMarkdown.parse(markdown); + + extractSiteTitle(); + extractRefLinks(markdown); + } + + private void extractSiteTitle() { + for (Node c = node.getFirstChild(); c != null; c = c.getNext()) { if (c instanceof Heading) { Heading h = (Heading) c; if (h.getLevel() == 1) { - data.put("siteTitle", MarkdownUtil.getInnerText(h)); + siteTitle = MarkdownUtil.getInnerText(h); h.unlink(); break; } } } + } - Matcher m = REF_LINK.matcher(navMarkdown); + private void extractRefLinks(String markdown) { + Matcher m = REF_LINK.matcher(markdown); while (m.find()) { String key = m.group(1).toLowerCase(); String url = m.group(2).trim(); switch (key) { case "logo": - data.put("logoUrl", toImgSrc(img, url)); + logoUrl = url; break; - case "home": - data.put("homeUrl", toHtml.href(url)); + homeUrl = url; break; } } - - return data; } - - private static Object toImgSrc(ImageLoader img, String url) { - if (HtmlBuilder.isValidHttpUri(url)) { - return url; - } - - if (HtmlBuilder.isImageDataUri(url)) { - return Sanitizers.filterImageDataUri(url); - } - - if (img != null) { - return Sanitizers.filterImageDataUri(img.loadImage(url)); - } - - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); - } - - private Navbar() {} }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java new file mode 100644 index 0000000..1cc8095 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java
@@ -0,0 +1,89 @@ +// Copyright (C) 2016 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.gitiles.doc; + +import com.google.common.base.CharMatcher; + +import javax.annotation.Nullable; + +class PathResolver { + /** + * Resolve a path within the repository. + * + * @param file path of the Markdown file in the repository that is making the + * reference. May be null. + * @param target destination within the repository. If {@code target} starts + * with {@code '/'}, {@code file} may be null and {@code target} is + * evaluated as from the root directory of the repository. + * @return resolved form of {@code target} within the repository. Null if + * {@code target} is not valid from {@code file}. Does not begin with + * {@code '/'}, even if {@code target} does. + */ + @Nullable + static String resolve(@Nullable String file, String target) { + if (target.startsWith("/")) { + return trimLeadingSlash(target); + } else if (file == null) { + return null; + } + + String dir = trimLastComponent(trimLeadingSlash(file)); + while (!target.isEmpty()) { + if (target.startsWith("../") || target.equals("..")) { + if (dir.isEmpty()) { + return null; + } + dir = trimLastComponent(dir); + target = target.equals("..") ? "" : target.substring(3); + } else if (target.startsWith("./")) { + target = target.substring(2); + } else if (target.equals(".")) { + target = ""; + } else { + break; + } + } + return trimLeadingSlash(dir + '/' + target); + } + + private static String trimLeadingSlash(String s) { + return CharMatcher.is('/').trimLeadingFrom(s); + } + + private static String trimLastComponent(String path) { + int slash = path.lastIndexOf('/'); + return slash < 0 ? "" : path.substring(0, slash); + } + + static String relative(@Nullable String requestUri, String dest) { + if (requestUri != null) { + // base is the path the browser will use for relative URLs. + String base = requestUri; + if (!base.endsWith("/")) { + int slash = base.lastIndexOf('/'); + if (slash < 0) { + return dest; + } + base = base.substring(0, slash + 1); + } + if (dest.startsWith(base)) { + return dest.substring(base.length()); + } + } + return dest; + } + + private PathResolver() {} +}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/mime-types.properties b/gitiles-servlet/src/main/resources/com/google/gitiles/mime-types.properties new file mode 100644 index 0000000..0b4bd26 --- /dev/null +++ b/gitiles-servlet/src/main/resources/com/google/gitiles/mime-types.properties
@@ -0,0 +1,16 @@ +bmp = image/bmp +css = text/css +csv = text/csv +gif = image/gif +htm = text/html +html = text/html +jpeg = image/jpeg +jpg = image/jpeg +js = application/javascript +md = text/markdown +pdf = application/pdf +png = image/png +svg = image/svg+xml +tiff = image/tiff +txt = text/plain +xml = text/xml
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java index 51154b0..fdffffc 100644 --- a/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java +++ b/gitiles-servlet/src/test/java/com/google/gitiles/doc/LinkTest.java
@@ -44,7 +44,12 @@ @Test public void httpLink() { - MarkdownToHtml md = new MarkdownToHtml(view, config, "index.md"); + MarkdownToHtml md = + MarkdownToHtml.builder() + .setGitilesView(view) + .setConfig(new MarkdownConfig(config)) + .setFilePath("index.md") + .build(); String url; url = "http://example.com/foo.html"; @@ -59,7 +64,12 @@ @Test public void absolutePath() { - MarkdownToHtml md = new MarkdownToHtml(view, config, "index.md"); + MarkdownToHtml md = + MarkdownToHtml.builder() + .setGitilesView(view) + .setConfig(new MarkdownConfig(config)) + .setFilePath("index.md") + .build(); assertThat(md.href("/")).isEqualTo("/g/repo/+/HEAD/"); assertThat(md.href("/index.md")).isEqualTo("/g/repo/+/HEAD/index.md"); @@ -133,10 +143,11 @@ } private MarkdownToHtml file(String path) { - return new MarkdownToHtml( - GitilesView.doc().copyFrom(view).setPathPart(path).build(), - config, - path); + return MarkdownToHtml.builder() + .setGitilesView(GitilesView.doc().copyFrom(view).setPathPart(path).build()) + .setConfig(new MarkdownConfig(config)) + .setFilePath(path) + .build(); } private MarkdownToHtml repoIndexReadme() { @@ -154,7 +165,11 @@ } private MarkdownToHtml readme(GitilesView v, String path) { - return new MarkdownToHtml(v, config, path); + return MarkdownToHtml.builder() + .setGitilesView(v) + .setConfig(new MarkdownConfig(config)) + .setFilePath(path) + .build(); } @Test @@ -200,12 +215,36 @@ } private MarkdownToHtml rootedDoc(String path, String file) { - GitilesView view = GitilesView.rootedDoc() - .setHostName("gerritcodereview.com") - .setServletPath("") - .setRevision(RootedDocServlet.BRANCH) - .setPathPart(path) + return MarkdownToHtml.builder() + .setGitilesView( + GitilesView.rootedDoc() + .setHostName("gerritcodereview.com") + .setServletPath("") + .setRevision(RootedDocServlet.BRANCH) + .setPathPart(path) + .build()) + .setConfig(new MarkdownConfig(config)) + .setFilePath(file) .build(); - return new MarkdownToHtml(view, config, file); + } + + @Test + public void automaticRelativePaths() { + MarkdownToHtml md = + MarkdownToHtml.builder() + .setGitilesView(GitilesView.doc().copyFrom(view).setPathPart("docs/index.md").build()) + .setConfig(new MarkdownConfig(config)) + .setFilePath("/docs/index.md") + .setRequestUri("/g/repo/+/HEAD/docs/index.md") + .build(); + + assertThat(md.href("help.md")).isEqualTo("help.md"); + assertThat(md.href("/docs/help.md")).isEqualTo("help.md"); + assertThat(md.href("technical/format.md")).isEqualTo("technical/format.md"); + assertThat(md.href("/docs/technical/format.md")).isEqualTo("technical/format.md"); + + assertThat(md.href("../README.md")).isEqualTo("/g/repo/+/HEAD/README.md"); + assertThat(md.href("../src/catalog.md")).isEqualTo("/g/repo/+/HEAD/src/catalog.md"); + assertThat(md.href("/src/catalog.md")).isEqualTo("/g/repo/+/HEAD/src/catalog.md"); } }
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/doc/PathResolverTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/doc/PathResolverTest.java new file mode 100644 index 0000000..f1990ec --- /dev/null +++ b/gitiles-servlet/src/test/java/com/google/gitiles/doc/PathResolverTest.java
@@ -0,0 +1,55 @@ +// Copyright (C) 2016 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.gitiles.doc; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gitiles.doc.PathResolver.relative; +import static com.google.gitiles.doc.PathResolver.resolve; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class PathResolverTest { + @Test + public void resolveTests() { + assertThat(resolve(null, "/foo.md")).isEqualTo("foo.md"); + assertThat(resolve(null, "////foo.md")).isEqualTo("foo.md"); + assertThat(resolve("/index.md", "/foo.md")).isEqualTo("foo.md"); + assertThat(resolve("index.md", "/foo.md")).isEqualTo("foo.md"); + assertThat(resolve("index.md", "foo.md")).isEqualTo("foo.md"); + assertThat(resolve(null, "foo.md")).isNull(); + + assertThat(resolve("doc/index.md", "../foo.md")).isEqualTo("foo.md"); + assertThat(resolve("/doc/index.md", "../foo.md")).isEqualTo("foo.md"); + assertThat(resolve("/doc/index.md", ".././foo.md")).isEqualTo("foo.md"); + assertThat(resolve("/a/b/c/index.md", "../../foo.md")).isEqualTo("a/foo.md"); + assertThat(resolve("/a/index.md", "../../../foo.md")).isNull(); + } + + @Test + public void relativeTests() { + assertThat(relative(null, "/g/foo.md")).isEqualTo("/g/foo.md"); + + assertThat(relative("/g", "/g/foo.md")).isEqualTo("g/foo.md"); + assertThat(relative("/r", "/g/foo.md")).isEqualTo("g/foo.md"); + assertThat(relative("/a/b/r", "/a/b/g/foo.md")).isEqualTo("g/foo.md"); + + assertThat(relative("/g/", "/g/foo.md")).isEqualTo("foo.md"); + assertThat(relative("/g/bar.md", "/g/foo.md")).isEqualTo("foo.md"); + assertThat(relative("/g/a/b.md", "/g/foo.md")).isEqualTo("/g/foo.md"); + } +}