Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions core/src/main/java/hudson/model/DirectoryBrowserSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -379,11 +379,20 @@
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);

Check warning on line 394 in core/src/main/java/hudson/model/DirectoryBrowserSupport.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 392-394 are not covered by tests
}
} else {
if (resourceToken != null) {
// redirect to second domain
Expand All @@ -407,16 +416,42 @@
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);

Check warning on line 434 in core/src/main/java/hudson/model/DirectoryBrowserSupport.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 432-434 are not covered by tests
}
}
}
}

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) {

Check warning on line 445 in core/src/main/java/hudson/model/DirectoryBrowserSupport.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 445 is only partially covered, one branch is missing
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);

Check warning on line 451 in core/src/main/java/hudson/model/DirectoryBrowserSupport.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 448-451 are not covered by tests
}
}
return context;
}

private record IsAbsolute(String fragment) implements ControllerToAgentCallable<Boolean, IOException> {
Expand Down
179 changes: 179 additions & 0 deletions core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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:
* <ul>
* <li>Decompressing gzipped files on the fly when requested by a user.</li>
* <li>Decrypting encrypted artifact contents.</li>
* </ul>
*
* @since TODO
*/
public abstract class DirectoryBrowserSupportFilter implements ExtensionPoint {

/**
* Context object passed to {@link DirectoryBrowserSupportFilter} implementations.
*
* <p>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<InputStream> 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);
}

Check warning on line 79 in core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 78-79 are not covered by tests

/**
* 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;

Check warning on line 101 in core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 94-101 are not covered by tests
}

/**
* Gets the current {@link InputStream} for the file content.
*/
@NonNull
public InputStream getInputStream() {
return inputStream;
}

/**
* Sets a new {@link InputStream} for the file content.
*
* <p>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) {
Comment on lines +113 to +119
if (this.inputStream != inputStream) {

Check warning on line 120 in core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 120 is only partially covered, one branch is missing
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;
}

Check warning on line 153 in core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 152-153 are not covered by tests

@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<DirectoryBrowserSupportFilter> all() {
return ExtensionList.lookup(DirectoryBrowserSupportFilter.class);
}
}
35 changes: 35 additions & 0 deletions test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Loading