From 3712c1fa1702d2d4b753b0c4d0612d7a5da038fa Mon Sep 17 00:00:00 2001 From: "Brian P. Hinz" Date: Sun, 9 Aug 2026 18:45:17 -0400 Subject: [PATCH] Introduce DirectoryBrowserSupportFilter extension point Allows plugins to filter or transform file input streams and HTTP response metadata served by DirectoryBrowserSupport (such as build artifacts and workspace files). Potential use cases for plugins include, for example: - Performing on-the-fly stream decompression (e.g., viewing GZIP artifacts). - Performing artifact decryption. Security & Resource Management: - DirectoryBrowserSupportFilter.Context implements AutoCloseable to track and close superseded streams, preventing resource leaks. - Fail-fast exception handling aborts failed filter executions cleanly. --- .../hudson/model/DirectoryBrowserSupport.java | 57 ++++-- .../model/DirectoryBrowserSupportFilter.java | 179 ++++++++++++++++++ .../model/DirectoryBrowserSupportTest.java | 35 ++++ 3 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java index 3151cc314810..98d88314c786 100644 --- a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java +++ b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java @@ -379,11 +379,20 @@ private void serveFile(StaplerRequest2 req, StaplerResponse2 rsp, VirtualFile ro return; } - // for binary files, provide the file name for download - rsp.setHeader("Content-Disposition", "inline; filename=" + baseFile.getName()); + try (DirectoryBrowserSupportFilter.Context context = applyFilters(req, baseFile, in, length, true)) { + in = context.getInputStream(); + length = context.getLength(); + String fileName = context.getFileName(); - // pseudo file name to let the Stapler set text/plain; ensure charset for non-ASCII text - rsp.serveFile(req, in, lastModified, -1, length, "mime-type:text/plain;charset=UTF-8"); + // for binary files, provide the file name for download + rsp.setHeader("Content-Disposition", "inline; filename=" + fileName); + + // pseudo file name to let Stapler set text/plain; ensure charset for non-ASCII text + rsp.serveFile(req, in, lastModified, -1, length, "mime-type:text/plain;charset=UTF-8"); + } catch (IOException ioe) { + LOGGER.log(Level.WARNING, "Failed to serve file for " + baseFile, ioe); + rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } } else { if (resourceToken != null) { // redirect to second domain @@ -407,16 +416,42 @@ private void serveFile(StaplerRequest2 req, StaplerResponse2 rsp, VirtualFile ro rsp.sendError(HttpServletResponse.SC_NOT_FOUND); return; } - String fileName = baseFile.getName(); - String mimeType = Jenkins.get().getServletContext().getMimeType(fileName); - if (mimeType != null && mimeType.startsWith("text/")) { - // include charset=UTF-8 for text files to prevent browser encoding guessing - rsp.serveFile(req, in, lastModified, -1, length, "mime-type:" + mimeType + ";charset=UTF-8"); - } else { - rsp.serveFile(req, in, lastModified, -1, length, fileName); + + try (DirectoryBrowserSupportFilter.Context context = applyFilters(req, baseFile, in, length, false)) { + in = context.getInputStream(); + length = context.getLength(); + String fileName = context.getFileName(); + + String mimeType = Jenkins.get().getServletContext().getMimeType(fileName); + if (mimeType != null && mimeType.startsWith("text/")) { + // include charset=UTF-8 for text files to prevent browser encoding guessing + rsp.serveFile(req, in, lastModified, -1, length, "mime-type:" + mimeType + ";charset=UTF-8"); + } else { + rsp.serveFile(req, in, lastModified, -1, length, fileName); + } + } catch (IOException ioe) { + LOGGER.log(Level.WARNING, "Failed to serve file for " + baseFile, ioe); + rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + } + } + + private DirectoryBrowserSupportFilter.Context applyFilters(StaplerRequest2 req, VirtualFile baseFile, InputStream in, long length, boolean view) throws IOException { + DirectoryBrowserSupportFilter.Context context = new DirectoryBrowserSupportFilter.Context(baseFile, req, in, length, view); + for (DirectoryBrowserSupportFilter filter : DirectoryBrowserSupportFilter.all()) { + try { + DirectoryBrowserSupportFilter.Context next = filter.filter(context); + if (next != null) { + context = next; } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to filter stream for " + baseFile + " using " + filter, e); + context.close(); + throw new IOException("Filter execution failed: " + filter.getClass().getName(), e); } } + return context; } private record IsAbsolute(String fragment) implements ControllerToAgentCallable { diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java b/core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java new file mode 100644 index 000000000000..b8c0aabb4b6a --- /dev/null +++ b/core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java @@ -0,0 +1,179 @@ +/* + * The MIT License + * + * Copyright (c) 2026, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.model; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import jenkins.util.VirtualFile; +import org.apache.commons.io.IOUtils; +import org.kohsuke.stapler.StaplerRequest2; + +/** + * Extension point that allows plugins to filter or transform input streams and metadata served by {@link DirectoryBrowserSupport}. + * + *

Plugins can implement this extension point to inspect, decode, or transform files served through + * {@link DirectoryBrowserSupport} (such as build artifacts or workspace files). Potential use cases include, for example: + *

+ * + * @since TODO + */ +public abstract class DirectoryBrowserSupportFilter implements ExtensionPoint { + + /** + * Context object passed to {@link DirectoryBrowserSupportFilter} implementations. + * + *

This object tracks the file stream and metadata during filtering. Superseded input streams replaced + * via {@link #setInputStream(InputStream)} are automatically tracked and closed when this context is closed. + */ + public static final class Context implements AutoCloseable { + private final VirtualFile file; + private final StaplerRequest2 request; + private final boolean view; + private final List supersededStreams = new ArrayList<>(); + private InputStream inputStream; + private long length; + private String fileName; + + public Context(@NonNull VirtualFile file, @Nullable StaplerRequest2 request, @NonNull InputStream inputStream, long length, boolean view) { + this.file = file; + this.request = request; + this.inputStream = inputStream; + this.length = length; + this.view = view; + this.fileName = file.getName(); + } + + public Context(@NonNull VirtualFile file, @Nullable StaplerRequest2 request, @NonNull InputStream inputStream, long length) { + this(file, request, inputStream, length, false); + } + + /** + * Gets the {@link VirtualFile} being served. + */ + @NonNull + public VirtualFile getFile() { + return file; + } + + /** + * Gets the current {@link StaplerRequest2}, if available. + */ + @Nullable + public StaplerRequest2 getRequest() { + return request; + } + + /** + * Returns whether the request was made in view mode (e.g., {@code /*view*\/}). + */ + public boolean isView() { + return view; + } + + /** + * Gets the current {@link InputStream} for the file content. + */ + @NonNull + public InputStream getInputStream() { + return inputStream; + } + + /** + * Sets a new {@link InputStream} for the file content. + * + *

If a previous stream is replaced by an independent stream (for example, reading all bytes into a new + * {@link java.io.ByteArrayInputStream}), the previous stream is marked as superseded and will be closed + * when this context is closed. + */ + public void setInputStream(@NonNull InputStream inputStream) { + if (this.inputStream != inputStream) { + this.supersededStreams.add(this.inputStream); + this.inputStream = inputStream; + } + } + + /** + * Gets the length of the stream content in bytes. + */ + public long getLength() { + return length; + } + + /** + * Sets the length of the stream content in bytes, or {@code -1} if the length is unknown. + */ + public void setLength(long length) { + this.length = length; + } + + /** + * Gets the filename used for response headers and MIME type matching. + */ + @NonNull + public String getFileName() { + return fileName; + } + + /** + * Sets a new filename for the served content. + */ + public void setFileName(@NonNull String fileName) { + this.fileName = fileName; + } + + @Override + public void close() { + for (InputStream s : supersededStreams) { + IOUtils.closeQuietly(s); + } + } + } + + /** + * Filters or transforms the given file serving context. + * + * @param context the current file serving context + * @return the transformed context (or the same context), or {@code null} if no changes are made + * @throws IOException if an I/O error occurs during filtering + */ + @Nullable + public abstract Context filter(@NonNull Context context) throws IOException; + + /** + * All registered {@link DirectoryBrowserSupportFilter} instances. + */ + public static ExtensionList all() { + return ExtensionList.lookup(DirectoryBrowserSupportFilter.class); + } +} diff --git a/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java b/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java index 8f31c39dba6d..0362659acd3c 100644 --- a/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java +++ b/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java @@ -1558,4 +1558,39 @@ public boolean delete() { } } + @TestExtension({"directoryBrowserSupportFilterTest", "directoryBrowserSupportNonViewFilterTest"}) + public static class TestGzipFilter extends DirectoryBrowserSupportFilter { + @Override + public Context filter(Context context) throws IOException { + if (context.getFile().getName().endsWith(".custom")) { + String modified = "MODIFIED: " + new String(context.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + byte[] bytes = modified.getBytes(java.nio.charset.StandardCharsets.UTF_8); + context.setInputStream(new java.io.ByteArrayInputStream(bytes)); + context.setLength(bytes.length); + } + return context; + } + } + + @Test + public void directoryBrowserSupportFilterTest() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + p.setScm(new SingleFileSCM("test.custom", "hello world")); + p.getPublishersList().add(new ArtifactArchiver("test.custom")); + j.buildAndAssertSuccess(p); + + org.htmlunit.Page page = j.createWebClient().goTo("job/" + p.getName() + "/lastSuccessfulBuild/artifact/test.custom/*view*/", "text/plain"); + assertEquals("MODIFIED: hello world", page.getWebResponse().getContentAsString()); + } + + @Test + public void directoryBrowserSupportNonViewFilterTest() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + p.setScm(new SingleFileSCM("test.custom", "hello world")); + p.getPublishersList().add(new ArtifactArchiver("test.custom")); + j.buildAndAssertSuccess(p); + + org.htmlunit.Page page = j.createWebClient().goTo("job/" + p.getName() + "/lastSuccessfulBuild/artifact/test.custom", "text/plain"); + assertEquals("MODIFIED: hello world", page.getWebResponse().getContentAsString()); + } }