diff --git a/README.adoc b/README.adoc index 8768b7d..3d7a241 100644 --- a/README.adoc +++ b/README.adoc @@ -74,3 +74,19 @@ http://support.quest.com/technical-documents/toad-edge/2.1/user-guide/continuous https://support.quest.com/technical-documents/toad-edge/2.2/user-guide/continuous-integration-and-delivery |=== + +[[ContinuousIntegrationWithToadEdgePlugin-Documentation]] +== Troubleshooting +If you are having issues viewing Jenkins HTML comparison report, it could be due to your browser's Content Security Policy. You can check your browser console to confirm the same. +Also, the reports will not get rendered if Jenkins's https://www.jenkins.io/doc/book/security/user-content/#resource-root-url[resource root url] is configured. + +To view Jenkins HTML reports, you would have to relax the CSP. This can be done by going to _Jenkins > Manage Jenkins > Script Console_ + +*Execute this script to relax CSP:* + +`System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "default-src 'self'; script-src * 'self' 'unsafe-inline'; img-src 'self'; style-src 'self' 'unsafe-inline'; font-src * data:");` + +This will be reverted once Jenkins restarts + +_Please note that we would also suggest you to review https://www.jenkins.io/doc/book/security/configuring-content-security-policy/ and https://content-security-policy.com/ to understand the nature of CSP and the protection which it offers before changing CSP._ + diff --git a/src/main/java/ci/with/toad/edge/DirectoryBrowserSupport.java b/src/main/java/ci/with/toad/edge/DirectoryBrowserSupport.java deleted file mode 100644 index 660085d..0000000 --- a/src/main/java/ci/with/toad/edge/DirectoryBrowserSupport.java +++ /dev/null @@ -1,641 +0,0 @@ -/* - * Copyright 2021 Quest Software Inc. - * ALL RIGHTS RESERVED. - * - * 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 expressor implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ci.with.toad.edge; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.Serializable; -import java.text.Collator; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; -import java.util.StringTokenizer; -import java.util.logging.Level; -import java.util.logging.Logger; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletResponse; - -import org.apache.commons.io.IOUtils; -import org.apache.tools.zip.ZipEntry; -import org.apache.tools.zip.ZipOutputStream; -import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.NoExternalUse; -import org.kohsuke.stapler.HttpResponse; -import org.kohsuke.stapler.StaplerRequest; -import org.kohsuke.stapler.StaplerResponse; -import hudson.FilePath; -import hudson.Util; -import hudson.model.ModelObject; -import jenkins.model.Jenkins; -import jenkins.security.MasterToSlaveCallable; -import jenkins.util.VirtualFile; - -/** - * Has convenience methods to serve file system. - * - *

- * This object can be used in a mix-in style to provide a directory browsing - * capability to a {@link ModelObject}. - * - * @author Kohsuke Kawaguchi - */ -public final class DirectoryBrowserSupport implements HttpResponse { - - public final ModelObject owner; - - public final String title; - - private final VirtualFile base; - private final String icon; - private final boolean serveDirIndex; - private String indexFileName = "index.html"; - - /** - * @param owner The parent model object under which the directory browsing is added. - * @param title Used in the HTML caption. - * - * @deprecated as of 1.297 Use - * {@link #DirectoryBrowserSupport(ModelObject, FilePath, String, String, boolean)} - */ - @Deprecated - public DirectoryBrowserSupport(ModelObject owner, String title) { - this(owner, (VirtualFile) null, title, null, false); - } - - /** - * @param owner - * The parent model object under which the directory browsing is - * added. - * @param base - * The root of the directory that's bound to URL. - * @param title - * Used in the HTML caption. - * @param icon - * The icon file name, like "folder.gif" - * @param serveDirIndex - * True to generate the directory index. False to serve - * "index.html" - */ - public DirectoryBrowserSupport(ModelObject owner, FilePath base, String title, String icon, boolean serveDirIndex) { - this(owner, base.toVirtualFile(), title, icon, serveDirIndex); - } - - /** - * @param owner - * The parent model object under which the directory browsing is - * added. - * @param base - * The root of the directory that's bound to URL. - * @param title - * Used in the HTML caption. - * @param icon - * The icon file name, like "folder.gif" - * @param serveDirIndex - * True to generate the directory index. False to serve - * "index.html" - * @since 1.532 - */ - public DirectoryBrowserSupport(ModelObject owner, VirtualFile base, String title, String icon, - boolean serveDirIndex) { - this.owner = owner; - this.base = base; - this.title = title; - this.icon = icon; - this.serveDirIndex = serveDirIndex; - } - - public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) - throws IOException, ServletException { - try { - serveFile(req, rsp, base, icon, serveDirIndex); - } catch (InterruptedException e) { - throw new IOException("interrupted", e); - } - } - - /** - * If the directory is requested but the directory listing is disabled, a - * file of this name is served. By default it's "index.html". - * - * @param fileName - * - name of the file to use as index - * - * @since 1.312 - */ - public void setIndexFileName(String fileName) { - this.indexFileName = fileName; - } - - /** - * Serves a file from the file system (Maps the URL to a directory in a file - * system.) - * - * @param req - * Request - * @param rsp - * Response - * @param root - * Server root - * @param icon - * The icon file name, like "folder-open.gif" - * @param serveDirIndex - * True to generate the directory index. False to serve - * "index.html" - * - * @throws IOException IOException - * @throws ServletException ServletException - * @throws InterruptedException InterruptedException - * - * @deprecated as of 1.297 Instead of calling this method explicitly, just - * return the {@link DirectoryBrowserSupport} object from the - * {@code doXYZ} method and let Stapler generate a response for - * you. - */ - @Deprecated - public void serveFile(StaplerRequest req, StaplerResponse rsp, FilePath root, String icon, boolean serveDirIndex) - throws IOException, ServletException, InterruptedException { - serveFile(req, rsp, root.toVirtualFile(), icon, serveDirIndex); - } - - private void serveFile(StaplerRequest req, StaplerResponse rsp, VirtualFile root, String icon, - boolean serveDirIndex) throws IOException, ServletException, InterruptedException { - // handle form submission - String pattern = req.getParameter("pattern"); - if (pattern == null) - pattern = req.getParameter("path"); // compatibility with - // Hudson<1.129 - if (pattern != null && !Util.isAbsoluteUri(pattern)) {// avoid open - // redirect - rsp.sendRedirect2(pattern); - return; - } - - String path = getPath(req); - if (path.replace('\\', '/').indexOf("/../") != -1) { - // don't serve anything other than files in the artifacts dir - rsp.sendError(HttpServletResponse.SC_BAD_REQUEST); - return; - } - - // split the path to the base directory portion "abc/def/ghi" which - // doesn't include any wildcard, - // and the GLOB portion "**/*.xml" (the rest) - StringBuilder _base = new StringBuilder(); - StringBuilder _rest = new StringBuilder(); - int restSize = -1; // number of ".." needed to go back to the 'base' - // level. - boolean zip = false; // if we are asked to serve a zip file bundle - boolean plain = false; // if asked to serve a plain text directory - // listing - { - boolean inBase = true; - StringTokenizer pathTokens = new StringTokenizer(path, "/"); - while (pathTokens.hasMoreTokens()) { - String pathElement = pathTokens.nextToken(); - // Treat * and ? as wildcard unless they match a literal - // filename - if ((pathElement.contains("?") || pathElement.contains("*")) && inBase - && !root.child((_base.length() > 0 ? _base + "/" : "") + pathElement).exists()) - inBase = false; - if (pathElement.equals("*zip*")) { - // the expected syntax is foo/bar/*zip*/bar.zip - // the last 'bar.zip' portion is to causes browses to set a - // good default file name. - // so the 'rest' portion ends here. - zip = true; - break; - } - if (pathElement.equals("*plain*")) { - plain = true; - break; - } - - StringBuilder sb = inBase ? _base : _rest; - if (sb.length() > 0) - sb.append('/'); - sb.append(pathElement); - if (!inBase) - restSize++; - } - } - restSize = Math.max(restSize, 0); - String base = _base.toString(); - String rest = _rest.toString(); - - // this is the base file/directory - VirtualFile baseFile = root.child(base); - - if (baseFile.isDirectory()) { - if (zip) { - rsp.setContentType("application/zip"); - zip(rsp.getOutputStream(), baseFile, rest); - return; - } - if (plain) { - rsp.setContentType("text/plain;charset=UTF-8"); - OutputStream os = rsp.getOutputStream(); - try { - for (VirtualFile kid : baseFile.list()) { - os.write(kid.getName().getBytes("UTF-8")); - if (kid.isDirectory()) { - os.write('/'); - } - os.write('\n'); - } - os.flush(); - } finally { - os.close(); - } - return; - } - - if (rest.length() == 0) { - // if the target page to be displayed is a directory and the - // path doesn't end with '/', redirect - StringBuffer reqUrl = req.getRequestURL(); - if (reqUrl.charAt(reqUrl.length() - 1) != '/') { - rsp.sendRedirect2(reqUrl.append('/').toString()); - return; - } - } - - List> glob = null; - - if (rest.length() > 0) { - // the rest is Ant glob pattern - glob = patternScan(baseFile, rest, createBackRef(restSize)); - } else if (serveDirIndex) { - // serve directory index - glob = baseFile.run(new BuildChildPaths(baseFile, req.getLocale())); - } - - if (glob != null) { - // serve glob - req.setAttribute("it", this); - List parentPaths = buildParentPath(base, restSize); - req.setAttribute("parentPath", parentPaths); - req.setAttribute("backPath", createBackRef(restSize)); - req.setAttribute("topPath", createBackRef(parentPaths.size() + restSize)); - req.setAttribute("files", glob); - req.setAttribute("icon", icon); - req.setAttribute("path", path); - req.setAttribute("pattern", rest); - req.setAttribute("dir", baseFile); - req.getView(this, "dir.jelly").forward(req, rsp); - return; - } - - // convert a directory service request to a single file service - // request by serving - // 'index.html' - baseFile = baseFile.child(indexFileName); - } - - // serve a single file - if (!baseFile.exists()) { - rsp.sendError(HttpServletResponse.SC_NOT_FOUND); - return; - } - - boolean view = rest.equals("*view*"); - - if (rest.equals("*fingerprint*")) { - InputStream fingerprintInput = baseFile.open(); - try { - Jenkins jenkins = Jenkins.getInstance(); - if (jenkins != null) { - rsp.forward(jenkins.getFingerprint(Util.getDigestOf(fingerprintInput)), "/", req); - } - } finally { - fingerprintInput.close(); - } - return; - } - - long lastModified = baseFile.lastModified(); - long length = baseFile.length(); - - if (LOGGER.isLoggable(Level.FINE)) - LOGGER.fine("Serving " + baseFile + " with lastModified=" + lastModified + ", length=" + length); - - InputStream in = baseFile.open(); - if (view) { - // for binary files, provide the file name for download - rsp.setHeader("Content-Disposition", "inline; filename=" + baseFile.getName()); - - // pseudo file name to let the Stapler set text/plain - rsp.serveFile(req, in, lastModified, -1, length, "plain.txt"); - } else { - rsp.serveFile(req, in, lastModified, -1, length, baseFile.getName()); - } - } - - private String getPath(StaplerRequest req) { - String path = req.getRestOfPath(); - if (path.length() == 0) - path = "/"; - return path; - } - - /** - * Builds a list of {@link Path} that represents ancestors from a string - * like "/foo/bar/zot". - */ - private List buildParentPath(String pathList, int restSize) { - List r = new ArrayList(); - StringTokenizer tokens = new StringTokenizer(pathList, "/"); - int total = tokens.countTokens(); - int current = 1; - while (tokens.hasMoreTokens()) { - String token = tokens.nextToken(); - r.add(new Path(createBackRef(total - current + restSize), token, true, 0, true)); - current++; - } - return r; - } - - private static String createBackRef(int times) { - if (times == 0) - return "./"; - StringBuilder buf = new StringBuilder(3 * times); - for (int i = 0; i < times; i++) - buf.append("../"); - return buf.toString(); - } - - private static void zip(OutputStream outputStream, VirtualFile dir, String glob) throws IOException { - ZipOutputStream zos = new ZipOutputStream(outputStream); - zos.setEncoding(System.getProperty("file.encoding")); // TODO - // JENKINS-20663 - // make this - // overridable - // via query - // parameter - for (String n : dir.list(glob.length() == 0 ? "**" : glob)) { - String relativePath; - if (glob.length() == 0) { - // JENKINS-19947: traditional behavior is to prepend the - // directory name - relativePath = dir.getName() + '/' + n; - } else { - relativePath = n; - } - // In ZIP archives "All slashes MUST be forward slashes" - // (http://pkware.com/documents/casestudies/APPNOTE.TXT) - // TODO On Linux file names can contain backslashes which should not - // treated as file separators. - // Unfortunately, only the file separator char of the master is - // known (File.separatorChar) - // but not the file separator char of the (maybe remote) "dir". - ZipEntry e = new ZipEntry(relativePath.replace('\\', '/')); - VirtualFile f = dir.child(n); - e.setTime(f.lastModified()); - zos.putNextEntry(e); - InputStream in = f.open(); - try { - Util.copyStream(in, zos); - } finally { - IOUtils.closeQuietly(in); - } - zos.closeEntry(); - } - zos.close(); - } - - /** - * Represents information about one file or folder. - */ - public static final class Path implements Serializable { - /** - * Relative URL to this path from the current page. - */ - private final String href; - /** - * Name of this path. Just the file name portion. - */ - private final String title; - - private final boolean isFolder; - - /** - * File size, or null if this is not a file. - */ - private final long size; - - /** - * If the current user can read the file. - */ - private final boolean isReadable; - - public Path(String href, String title, boolean isFolder, long size, boolean isReadable) { - this.href = href; - this.title = title; - this.isFolder = isFolder; - this.size = size; - this.isReadable = isReadable; - } - - public boolean isFolder() { - return isFolder; - } - - public boolean isReadable() { - return isReadable; - } - - public String getHref() { - return href; - } - - public String getTitle() { - return title; - } - - public String getIconName() { - if (isReadable) - return isFolder ? "folder.png" : "text.png"; - else - return isFolder ? "folder-error.png" : "text-error.png"; - } - - public String getIconClassName() { - if (isReadable) - return isFolder ? "icon-folder" : "icon-text"; - else - return isFolder ? "icon-folder-error" : "icon-text-error"; - } - - public long getSize() { - return size; - } - - private static final long serialVersionUID = 1L; - } - - private static final class FileComparator implements Comparator { - private Collator collator; - - FileComparator(Locale locale) { - this.collator = Collator.getInstance(locale); - } - - public int compare(VirtualFile lhs, VirtualFile rhs) { - // directories first, files next - int r = dirRank(lhs) - dirRank(rhs); - if (r != 0) - return r; - // otherwise alphabetical - return this.collator.compare(lhs.getName(), rhs.getName()); - } - - private int dirRank(VirtualFile f) { - try { - if (f.isDirectory()) - return 0; - else - return 1; - } catch (IOException ex) { - return 0; - } - } - } - - private static final class BuildChildPaths extends MasterToSlaveCallable>, IOException> { - private final VirtualFile cur; - private final Locale locale; - - BuildChildPaths(VirtualFile cur, Locale locale) { - this.cur = cur; - this.locale = locale; - } - - @Override - public List> call() throws IOException { - return buildChildPaths(cur, locale); - } - } - - /** - * Builds a list of list of {@link Path}. The inner list of {@link Path} - * represents one child item to be shown (this mechanism is used to skip - * empty intermediate directory.) - */ - private static List> buildChildPaths(VirtualFile cur, Locale locale) throws IOException { - List> r = new ArrayList>(); - - VirtualFile[] files = cur.list(); - Arrays.sort(files, new FileComparator(locale)); - - for (VirtualFile f : files) { - Path p = new Path(Util.rawEncode(f.getName()), f.getName(), f.isDirectory(), f.length(), f.canRead()); - if (!f.isDirectory()) { - r.add(Collections.singletonList(p)); - } else { - // find all empty intermediate directory - List l = new ArrayList(); - l.add(p); - StringBuilder relPath = new StringBuilder(Util.rawEncode(f.getName())); - while (true) { - // files that don't start with '.' qualify for 'meaningful - // files', nor SCM related files - List sub = new ArrayList(); - for (VirtualFile vf : f.list()) { - String name = vf.getName(); - if (!name.startsWith(".") && !name.equals("CVS") && !name.equals(".svn")) { - sub.add(vf); - } - } - if (sub.size() != 1 || !sub.get(0).isDirectory()) - break; - f = sub.get(0); - relPath.append('/' + Util.rawEncode(f.getName())); - l.add(new Path(relPath.toString(), f.getName(), true, 0, f.canRead())); - } - r.add(l); - } - } - - return r; - } - - /** - * Runs ant GLOB against the current {@link FilePath} and returns matching - * paths. - * - * @param baseRef - * String like "../../../" that cancels the 'rest' portion. Can - * be "./" - */ - private static List> patternScan(VirtualFile baseDir, String pattern, String baseRef) - throws IOException { - String[] files = baseDir.list(pattern); - - if (files.length > 0) { - List> r = new ArrayList>(files.length); - for (String match : files) { - List file = buildPathList(baseDir, baseDir.child(match), baseRef); - r.add(file); - } - return r; - } - - return null; - } - - /** - * Builds a path list from the current workspace directory down to the - * specified file path. - */ - private static List buildPathList(VirtualFile baseDir, VirtualFile filePath, String baseRef) - throws IOException { - List pathList = new ArrayList(); - StringBuilder href = new StringBuilder(baseRef); - - buildPathList(baseDir, filePath, pathList, href); - return pathList; - } - - /** - * Builds the path list and href recursively top-down. - */ - private static void buildPathList(VirtualFile baseDir, VirtualFile filePath, List pathList, - StringBuilder href) throws IOException { - VirtualFile parent = filePath.getParent(); - if (!baseDir.equals(parent)) { - buildPathList(baseDir, parent, pathList, href); - } - - href.append(Util.rawEncode(filePath.getName())); - if (filePath.isDirectory()) { - href.append("/"); - } - - Path path = new Path(href.toString(), filePath.getName(), filePath.isDirectory(), filePath.length(), - filePath.canRead()); - pathList.add(path); - } - - private static final Logger LOGGER = Logger.getLogger(DirectoryBrowserSupport.class.getName()); - - @Restricted(NoExternalUse.class) - public static final String DEFAULT_CSP_VALUE = "sandbox; default-src 'none'; img-src 'self'; style-src 'self';"; -} diff --git a/src/main/java/ci/with/toad/edge/ReportPublisherAction.java b/src/main/java/ci/with/toad/edge/ReportPublisherAction.java index 22d449e..0b4133b 100644 --- a/src/main/java/ci/with/toad/edge/ReportPublisherAction.java +++ b/src/main/java/ci/with/toad/edge/ReportPublisherAction.java @@ -27,6 +27,7 @@ import hudson.FilePath; import hudson.model.Action; +import hudson.model.DirectoryBrowserSupport; import hudson.model.Run; import jenkins.model.RunAction2;