001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.io.IOException;
025import java.io.InputStream;
026import java.io.UncheckedIOException;
027import java.nio.charset.StandardCharsets;
028import java.nio.file.FileSystems;
029import java.nio.file.Files;
030import java.nio.file.InvalidPathException;
031import java.nio.file.LinkOption;
032import java.nio.file.Path;
033import java.nio.file.attribute.BasicFileAttributes;
034import java.security.MessageDigest;
035import java.security.NoSuchAlgorithmException;
036import java.time.Instant;
037import java.util.Collections;
038import java.util.HexFormat;
039import java.util.LinkedHashMap;
040import java.util.LinkedHashSet;
041import java.util.List;
042import java.util.Map;
043import java.util.Optional;
044import java.util.Set;
045
046import static java.lang.String.format;
047import static java.util.Objects.requireNonNull;
048
049/**
050 * Safe helper for serving files from a configured root directory.
051 *
052 * @author <a href="https://www.revetkn.com">Mark Allen</a>
053 */
054@ThreadSafe
055public final class StaticFiles {
056        @NonNull
057        private static final Integer MAX_RELATIVE_PATH_UTF_8_LENGTH;
058        @NonNull
059        private static final Integer MAX_RELATIVE_PATH_SEGMENTS;
060        @NonNull
061        private static final Integer CONTENT_HASH_BUFFER_SIZE;
062        @NonNull
063        private static final HexFormat HEX_FORMAT;
064
065        static {
066                MAX_RELATIVE_PATH_UTF_8_LENGTH = 4096;
067                MAX_RELATIVE_PATH_SEGMENTS = 256;
068                CONTENT_HASH_BUFFER_SIZE = 8192;
069                HEX_FORMAT = HexFormat.of();
070        }
071
072        @NonNull
073        private final Path root;
074        @NonNull
075        private final Path noFollowRoot;
076        @NonNull
077        private final Path followRoot;
078        @NonNull
079        private final List<@NonNull String> indexFileNames;
080        @NonNull
081        private final MimeTypeResolver mimeTypeResolver;
082        @NonNull
083        private final EntityTagResolver entityTagResolver;
084        @NonNull
085        private final AccessResolver accessResolver;
086        @NonNull
087        private final LastModifiedResolver lastModifiedResolver;
088        @NonNull
089        private final CacheControlResolver cacheControlResolver;
090        @NonNull
091        private final HeadersResolver headersResolver;
092        @NonNull
093        private final RangeRequestsResolver rangeRequestsResolver;
094        @NonNull
095        private final Boolean followSymlinks;
096
097        /**
098         * Begins configuration for serving files under {@code root}.
099         * <p>
100         * Relative roots are resolved against the JVM's current working directory when the builder is
101         * built. Production deployments should generally pass an absolute path.
102         * <p>
103         * File matching follows the underlying filesystem's case-sensitivity and Unicode normalization
104         * rules. Production deployments that need portable behavior across operating systems should
105         * standardize on ASCII-safe asset names.
106         *
107         * @param root the static-file root directory
108         * @return a builder for static-file responses
109         */
110        @NonNull
111        public static Builder withRoot(@NonNull Path root) {
112                requireNonNull(root);
113                return new Builder(root);
114        }
115
116        private StaticFiles(@NonNull Builder builder) {
117                requireNonNull(builder);
118
119                this.root = builder.root.toAbsolutePath().normalize();
120                this.followSymlinks = builder.followSymlinks == null ? false : builder.followSymlinks;
121                this.indexFileNames = List.copyOf(builder.indexFileNames == null ? List.of() : builder.indexFileNames);
122                this.mimeTypeResolver = defaultingMimeTypeResolver(builder.mimeTypeResolver);
123                this.entityTagResolver = builder.entityTagResolver == null ? EntityTagResolver.defaultInstance() : builder.entityTagResolver;
124                this.accessResolver = builder.accessResolver == null ? AccessResolver.allowAllInstance() : builder.accessResolver;
125                this.lastModifiedResolver = builder.lastModifiedResolver == null ? LastModifiedResolver.fromAttributes() : builder.lastModifiedResolver;
126                this.cacheControlResolver = builder.cacheControlResolver == null ? CacheControlResolver.disabledInstance() : builder.cacheControlResolver;
127                this.headersResolver = builder.headersResolver == null ? HeadersResolver.disabledInstance() : builder.headersResolver;
128                this.rangeRequestsResolver = builder.rangeRequestsResolver == null ? RangeRequestsResolver.enabledInstance() : builder.rangeRequestsResolver;
129
130                for (String indexFileName : this.indexFileNames)
131                        validateIndexFileName(indexFileName);
132
133                try {
134                        this.noFollowRoot = this.root.toRealPath(LinkOption.NOFOLLOW_LINKS);
135                        this.followRoot = this.root.toRealPath();
136                } catch (IOException e) {
137                        throw new UncheckedIOException(format("Unable to resolve static file root '%s'.", this.root), e);
138                }
139
140                if (getFollowSymlinks()) {
141                        if (!Files.isDirectory(this.followRoot))
142                                throw new IllegalArgumentException(format("Static file root '%s' is not a directory.", this.root));
143                } else if (!Files.isDirectory(this.noFollowRoot, LinkOption.NOFOLLOW_LINKS)) {
144                        throw new IllegalArgumentException(format("Static file root '%s' is not a directory.", this.root));
145                }
146        }
147
148        /**
149         * Produces a marshaled response for {@code relativePath}, if the request targets a safe, readable
150         * file under the configured root.
151         * <p>
152         * Returns {@link Optional#empty()} for HTTP methods other than {@link HttpMethod#GET} and
153         * {@link HttpMethod#HEAD}, unsafe path input, directories without a configured index file,
154         * unreadable files, and missing files. Once a path resolves, non-{@code 200} file responses such as
155         * {@code 206}, {@code 304}, {@code 412}, and {@code 416} are returned inside the optional.
156         * <p>
157         * Resolvers receive the resolved file path after index-file resolution, not the original
158         * {@code relativePath}.
159         *
160         * @param relativePath the root-relative file path
161         * @param request the incoming request
162         * @return the marshaled response, if a static file was resolved
163         */
164        @NonNull
165        public Optional<MarshaledResponse> marshaledResponseFor(@NonNull String relativePath,
166                                                                                                                                                                                                                                        @NonNull Request request) {
167                requireNonNull(relativePath);
168                requireNonNull(request);
169
170                if (request.getHttpMethod() != HttpMethod.GET && request.getHttpMethod() != HttpMethod.HEAD)
171                        return Optional.empty();
172
173                ResolvedFile resolvedFile = resolvedFileFor(relativePath).orElse(null);
174
175                if (resolvedFile == null)
176                        return Optional.empty();
177
178                Path file = resolvedFile.path();
179                BasicFileAttributes attributes = resolvedFile.attributes();
180                Access access = requireNonNull(getAccessResolver().accessFor(file, attributes), "accessResolver returned null; return Access.ALLOW, Access.DENY, or Access.HIDE.");
181
182                if (access == Access.HIDE)
183                        return Optional.empty();
184
185                if (access == Access.DENY)
186                        // Access denial is not a file representation, so validators and file headers do not apply.
187                        return Optional.of(MarshaledResponse.fromStatusCode(StatusCode.HTTP_403.getStatusCode()));
188
189                EntityTag entityTag = requireNonNull(getEntityTagResolver().entityTagFor(file, attributes), "entityTagResolver returned null; use Optional.empty() to omit the header.").orElse(null);
190                Instant lastModified = requireNonNull(getLastModifiedResolver().lastModifiedFor(file, attributes), "lastModifiedResolver returned null; use Optional.empty() to omit the header.").orElse(null);
191                String cacheControl = requireNonNull(getCacheControlResolver().cacheControlFor(file, attributes), "cacheControlResolver returned null; use Optional.empty() to omit the header.").orElse(null);
192                Map<String, Set<String>> headers = requireNonNull(getHeadersResolver().headersFor(file, attributes), "headersResolver returned null; return an empty map to omit extra headers.");
193                Boolean rangeRequests = requireNonNull(getRangeRequestsResolver().rangeRequestsFor(file, attributes), "rangeRequestsResolver returned null; return false to disable range requests.");
194                String contentType = requireNonNull(getMimeTypeResolver().contentTypeFor(file), "mimeTypeResolver returned null; use Optional.empty() to omit Content-Type.").orElse(null);
195
196                MarshaledResponse response = MarshaledResponse.withFile(file, request, attributes)
197                                .contentType(contentType)
198                                .entityTag(entityTag)
199                                .lastModified(lastModified)
200                                .cacheControl(cacheControl)
201                                .headers(headers)
202                                .rangeRequests(rangeRequests)
203                                .build();
204
205                return Optional.of(response);
206        }
207
208        @NonNull
209        private Path getResolutionRoot() {
210                return getFollowSymlinks() ? this.followRoot : this.noFollowRoot;
211        }
212
213        @NonNull
214        private Boolean getFollowSymlinks() {
215                return this.followSymlinks;
216        }
217
218        @NonNull
219        private List<@NonNull String> getIndexFileNames() {
220                return this.indexFileNames;
221        }
222
223        @NonNull
224        private MimeTypeResolver getMimeTypeResolver() {
225                return this.mimeTypeResolver;
226        }
227
228        @NonNull
229        private EntityTagResolver getEntityTagResolver() {
230                return this.entityTagResolver;
231        }
232
233        @NonNull
234        private AccessResolver getAccessResolver() {
235                return this.accessResolver;
236        }
237
238        @NonNull
239        private LastModifiedResolver getLastModifiedResolver() {
240                return this.lastModifiedResolver;
241        }
242
243        @NonNull
244        private CacheControlResolver getCacheControlResolver() {
245                return this.cacheControlResolver;
246        }
247
248        @NonNull
249        private HeadersResolver getHeadersResolver() {
250                return this.headersResolver;
251        }
252
253        @NonNull
254        private RangeRequestsResolver getRangeRequestsResolver() {
255                return this.rangeRequestsResolver;
256        }
257
258        @NonNull
259        private Optional<ResolvedFile> resolvedFileFor(@NonNull String relativePath) {
260                requireNonNull(relativePath);
261
262                if (relativePath.getBytes(StandardCharsets.UTF_8).length > MAX_RELATIVE_PATH_UTF_8_LENGTH)
263                        return Optional.empty();
264
265                if (relativePath.split("/", -1).length > MAX_RELATIVE_PATH_SEGMENTS)
266                        return Optional.empty();
267
268                if (containsControlCharacter(relativePath))
269                        return Optional.empty();
270
271                if (relativePath.isEmpty() && getIndexFileNames().isEmpty())
272                        return Optional.empty();
273
274                if (looksLikeWindowsDrivePath(relativePath) || looksLikeUncPath(relativePath) || relativePath.indexOf('\\') >= 0)
275                        return Optional.empty();
276
277                Path relative;
278
279                try {
280                        relative = FileSystems.getDefault().getPath(relativePath);
281                } catch (InvalidPathException e) {
282                        return Optional.empty();
283                }
284
285                if (relative.isAbsolute())
286                        return Optional.empty();
287
288                for (Path segment : relative) {
289                        if ("..".equals(segment.toString()))
290                                return Optional.empty();
291                }
292
293                Path candidate = getResolutionRoot().resolve(relative).normalize();
294
295                if (!candidate.startsWith(getResolutionRoot()))
296                        return Optional.empty();
297
298                return resolvedFileForCandidate(candidate);
299        }
300
301        @NonNull
302        private Optional<ResolvedFile> resolvedFileForCandidate(@NonNull Path candidate) {
303                requireNonNull(candidate);
304
305                if (getFollowSymlinks())
306                        return followedResolvedFileForCandidate(candidate);
307
308                return noFollowResolvedFileForCandidate(candidate);
309        }
310
311        @NonNull
312        private Optional<ResolvedFile> noFollowResolvedFileForCandidate(@NonNull Path candidate) {
313                requireNonNull(candidate);
314
315                if (hasSymlinkComponent(candidate))
316                        return Optional.empty();
317
318                if (Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) {
319                        for (String indexFileName : getIndexFileNames()) {
320                                Path indexCandidate = candidate.resolve(indexFileName).normalize();
321
322                                if (!indexCandidate.startsWith(getResolutionRoot()) || hasSymlinkComponent(indexCandidate))
323                                        continue;
324
325                                Optional<ResolvedFile> resolvedIndexFile = noFollowRegularFile(indexCandidate);
326
327                                if (resolvedIndexFile.isPresent())
328                                        return resolvedIndexFile;
329                        }
330
331                        return Optional.empty();
332                }
333
334                return noFollowRegularFile(candidate);
335        }
336
337        @NonNull
338        private Optional<ResolvedFile> noFollowRegularFile(@NonNull Path candidate) {
339                requireNonNull(candidate);
340
341                if (!Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS) || !Files.isReadable(candidate))
342                        return Optional.empty();
343
344                try {
345                        return Optional.of(new ResolvedFile(candidate, Files.readAttributes(candidate, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)));
346                } catch (IOException e) {
347                        return Optional.empty();
348                }
349        }
350
351        @NonNull
352        private Optional<ResolvedFile> followedResolvedFileForCandidate(@NonNull Path candidate) {
353                requireNonNull(candidate);
354
355                Path realCandidate;
356
357                try {
358                        realCandidate = candidate.toRealPath();
359                } catch (IOException e) {
360                        return Optional.empty();
361                }
362
363                if (!realCandidate.startsWith(this.followRoot))
364                        return Optional.empty();
365
366                if (Files.isDirectory(realCandidate)) {
367                        for (String indexFileName : getIndexFileNames()) {
368                                Path indexCandidate = realCandidate.resolve(indexFileName).normalize();
369
370                                if (!indexCandidate.startsWith(this.followRoot))
371                                        continue;
372
373                                Optional<ResolvedFile> resolvedIndexFile = followedRegularFile(indexCandidate);
374
375                                if (resolvedIndexFile.isPresent())
376                                        return resolvedIndexFile;
377                        }
378
379                        return Optional.empty();
380                }
381
382                return followedRegularFile(realCandidate);
383        }
384
385        @NonNull
386        private Optional<ResolvedFile> followedRegularFile(@NonNull Path candidate) {
387                requireNonNull(candidate);
388
389                if (!Files.isRegularFile(candidate) || !Files.isReadable(candidate))
390                        return Optional.empty();
391
392                try {
393                        Path realCandidate = candidate.toRealPath();
394
395                        if (!realCandidate.startsWith(this.followRoot))
396                                return Optional.empty();
397
398                        return Optional.of(new ResolvedFile(realCandidate, Files.readAttributes(realCandidate, BasicFileAttributes.class)));
399                } catch (IOException e) {
400                        return Optional.empty();
401                }
402        }
403
404        @NonNull
405        private Boolean hasSymlinkComponent(@NonNull Path candidate) {
406                requireNonNull(candidate);
407
408                Path relative = getResolutionRoot().relativize(candidate);
409                Path current = getResolutionRoot();
410
411                for (Path segment : relative) {
412                        current = current.resolve(segment);
413
414                        if (Files.isSymbolicLink(current))
415                                return true;
416                }
417
418                return false;
419        }
420
421        private static void validateIndexFileName(@NonNull String indexFileName) {
422                requireNonNull(indexFileName);
423
424                if (indexFileName.isEmpty()
425                                || containsControlCharacter(indexFileName)
426                                || indexFileName.indexOf('/') >= 0
427                                || indexFileName.indexOf('\\') >= 0
428                                || ".".equals(indexFileName)
429                                || "..".equals(indexFileName)
430                                || looksLikeWindowsDrivePath(indexFileName)
431                                || looksLikeUncPath(indexFileName))
432                        throw new IllegalArgumentException(format("Invalid index file name '%s'.", indexFileName));
433        }
434
435        @NonNull
436        private static Boolean containsControlCharacter(@NonNull String value) {
437                requireNonNull(value);
438
439                for (int i = 0; i < value.length(); i++) {
440                        if (Character.isISOControl(value.charAt(i)))
441                                return true;
442                }
443
444                return false;
445        }
446
447        @NonNull
448        private static Boolean looksLikeWindowsDrivePath(@NonNull String value) {
449                requireNonNull(value);
450                return value.length() >= 2 && Character.isLetter(value.charAt(0)) && value.charAt(1) == ':';
451        }
452
453        @NonNull
454        private static Boolean looksLikeUncPath(@NonNull String value) {
455                requireNonNull(value);
456                return value.startsWith("//") || value.startsWith("\\\\");
457        }
458
459        @NonNull
460        private static MimeTypeResolver defaultingMimeTypeResolver(@Nullable MimeTypeResolver userResolver) {
461                return userResolver == null ? MimeTypeResolver.defaultInstance() : userResolver;
462        }
463
464        @NonNull
465        private static Map<@NonNull String, @NonNull Set<@NonNull String>> copyHeaders(
466                        @NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
467                requireNonNull(headers);
468
469                Map<String, Set<String>> copiedHeaders = new LinkedHashMap<>();
470
471                for (Map.Entry<String, Set<String>> entry : headers.entrySet()) {
472                        String headerName = requireNonNull(entry.getKey());
473                        Set<String> copiedHeaderValues = new LinkedHashSet<>(requireNonNull(entry.getValue()));
474                        copiedHeaderValues.forEach(value -> requireNonNull(value, format("Header '%s' includes a null value.", headerName)));
475                        copiedHeaders.put(headerName, Collections.unmodifiableSet(copiedHeaderValues));
476                }
477
478                return Collections.unmodifiableMap(copiedHeaders);
479        }
480
481        private record ResolvedFile(@NonNull Path path,
482                                                                                                                        @NonNull BasicFileAttributes attributes) {}
483
484        /**
485         * Builder for {@link StaticFiles} instances.
486         *
487         * @author <a href="https://www.revetkn.com">Mark Allen</a>
488         */
489        @NotThreadSafe
490        public static final class Builder {
491                @NonNull
492                private final Path root;
493                @Nullable
494                private List<@NonNull String> indexFileNames;
495                @Nullable
496                private MimeTypeResolver mimeTypeResolver;
497                @Nullable
498                private EntityTagResolver entityTagResolver;
499                @Nullable
500                private AccessResolver accessResolver;
501                @Nullable
502                private LastModifiedResolver lastModifiedResolver;
503                @Nullable
504                private CacheControlResolver cacheControlResolver;
505                @Nullable
506                private HeadersResolver headersResolver;
507                @Nullable
508                private RangeRequestsResolver rangeRequestsResolver;
509                @Nullable
510                private Boolean followSymlinks;
511
512                private Builder(@NonNull Path root) {
513                        requireNonNull(root);
514                        this.root = root;
515                }
516
517                /**
518                 * Sets the index file names to try when a request path resolves to a directory.
519                 * <p>
520                 * Names are tried in list order. Each name must be a single file name, not a path; blank
521                 * names, {@code .}, {@code ..}, path separators, control characters, Windows drive paths, and
522                 * UNC-style paths are rejected when the builder is built.
523                 *
524                 * @param indexFileNames the index file names, or {@code null} for no index files
525                 * @return this builder
526                 */
527                @NonNull
528                public Builder indexFileNames(@Nullable List<@NonNull String> indexFileNames) {
529                        this.indexFileNames = indexFileNames;
530                        return this;
531                }
532
533                /**
534                 * Sets the resolver used to produce {@code Content-Type} values.
535                 * <p>
536                 * The configured resolver fully replaces the default resolver. Soklet's default resolver uses a
537                 * curated deterministic set of common web-asset extensions, returns {@link Optional#empty()} for
538                 * unknown extensions, and does not call {@link Files#probeContentType(Path)}. Applications that
539                 * need OS-level MIME database behavior can configure a resolver that calls
540                 * {@code Files.probeContentType(...)} directly.
541                 *
542                 * @param mimeTypeResolver the resolver to use, or {@code null} to restore the default resolver
543                 * @return this builder
544                 */
545                @NonNull
546                public Builder mimeTypeResolver(@Nullable MimeTypeResolver mimeTypeResolver) {
547                        this.mimeTypeResolver = mimeTypeResolver;
548                        return this;
549                }
550
551                /**
552                 * Sets the resolver used to produce {@code ETag} values.
553                 * <p>
554                 * Passing {@code null} restores the default weak metadata-based resolver.
555                 *
556                 * @param entityTagResolver the resolver to use, or {@code null} to restore the default resolver
557                 * @return this builder
558                 */
559                @NonNull
560                public Builder entityTagResolver(@Nullable EntityTagResolver entityTagResolver) {
561                        this.entityTagResolver = entityTagResolver;
562                        return this;
563                }
564
565                /**
566                 * Sets the resolver used to decide whether resolved files are served, denied, or hidden.
567                 * <p>
568                 * The access resolver runs after path safety, index-file resolution, symlink policy,
569                 * readability, regular-file checks, and attribute reads, but before MIME, validator,
570                 * cache-control, extra-header, and range-request resolvers. It receives the resolved file path,
571                 * not the original request path.
572                 *
573                 * @param accessResolver the resolver to use, or {@code null} to restore the allow-all default
574                 * @return this builder
575                 */
576                @NonNull
577                public Builder accessResolver(@Nullable AccessResolver accessResolver) {
578                        this.accessResolver = accessResolver;
579                        return this;
580                }
581
582                /**
583                 * Sets the resolver used to produce {@code Last-Modified} values.
584                 * <p>
585                 * Passing {@code null} restores the default resolver, which uses the file attributes read for
586                 * the response.
587                 *
588                 * @param lastModifiedResolver the resolver to use, or {@code null} to restore the default resolver
589                 * @return this builder
590                 */
591                @NonNull
592                public Builder lastModifiedResolver(@Nullable LastModifiedResolver lastModifiedResolver) {
593                        this.lastModifiedResolver = lastModifiedResolver;
594                        return this;
595                }
596
597                /**
598                 * Sets the resolver used to produce {@code Cache-Control} values.
599                 * <p>
600                 * Passing {@code null} restores the disabled resolver, which omits {@code Cache-Control}.
601                 *
602                 * @param cacheControlResolver the resolver to use, or {@code null} to omit {@code Cache-Control}
603                 * @return this builder
604                 */
605                @NonNull
606                public Builder cacheControlResolver(@Nullable CacheControlResolver cacheControlResolver) {
607                        this.cacheControlResolver = cacheControlResolver;
608                        return this;
609                }
610
611                /**
612                 * Sets the resolver used to produce extra response headers.
613                 * <p>
614                 * Passing {@code null} restores the disabled resolver, which emits no extra headers.
615                 *
616                 * @param headersResolver the resolver to use, or {@code null} to omit extra headers
617                 * @return this builder
618                 */
619                @NonNull
620                public Builder headersResolver(@Nullable HeadersResolver headersResolver) {
621                        this.headersResolver = headersResolver;
622                        return this;
623                }
624
625                /**
626                 * Sets the resolver used to decide whether byte range requests are honored.
627                 * <p>
628                 * Passing {@code null} restores the default resolver, which enables range requests.
629                 *
630                 * @param rangeRequestsResolver the resolver to use, or {@code null} to enable range requests
631                 * @return this builder
632                 */
633                @NonNull
634                public Builder rangeRequestsResolver(@Nullable RangeRequestsResolver rangeRequestsResolver) {
635                        this.rangeRequestsResolver = rangeRequestsResolver;
636                        return this;
637                }
638
639                /**
640                 * Sets whether symbolic links are followed while resolving static-file paths.
641                 * <p>
642                 * Passing {@code null} restores the default of {@code false}. When disabled, any symbolic-link
643                 * component under the static root causes the candidate path to be hidden.
644                 *
645                 * @param followSymlinks {@code true} to follow symlinks, {@code false} to reject them, or
646                 * {@code null} for the default
647                 * @return this builder
648                 */
649                @NonNull
650                public Builder followSymlinks(@Nullable Boolean followSymlinks) {
651                        this.followSymlinks = followSymlinks;
652                        return this;
653                }
654
655                /**
656                 * Builds the static-file helper.
657                 * <p>
658                 * The configured root is resolved to a real path and validated as a directory when this method
659                 * is called.
660                 *
661                 * @return the configured static-file helper
662                 */
663                @NonNull
664                public StaticFiles build() {
665                        return new StaticFiles(this);
666                }
667        }
668
669        /**
670         * Resolves {@code ETag} values for static-file responses.
671         *
672         * @author <a href="https://www.revetkn.com">Mark Allen</a>
673         */
674        @FunctionalInterface
675        public interface EntityTagResolver {
676                /**
677                 * Returns the default weak metadata-based ETag resolver.
678                 * <p>
679                 * The default resolver produces a weak ETag derived from the file's last-modified epoch second
680                 * and size. This is deterministic across processes serving the same filesystem. Configure a
681                 * custom resolver, such as a content-hash resolver, when serving from filesystems that do not
682                 * preserve modification times, when same-second overwrites are common, or when stronger
683                 * collision resistance is required.
684                 *
685                 * @return the default entity-tag resolver
686                 */
687                @NonNull
688                static EntityTagResolver defaultInstance() {
689                        return DefaultEntityTagResolver.defaultInstance();
690                }
691
692                /**
693                 * Returns a resolver that omits {@code ETag}.
694                 *
695                 * @return the disabled entity-tag resolver
696                 */
697                @NonNull
698                static EntityTagResolver disabledInstance() {
699                        return DisabledEntityTagResolver.defaultInstance();
700                }
701
702                /**
703                 * Returns a strong content-hash ETag resolver.
704                 * <p>
705                 * This resolver streams the served file through SHA-256 on the request-handling thread and
706                 * emits strong ETags with values of the form {@code sha256-&lt;lowercase-hex&gt;}. It does not
707                 * cache digests and it fully reads the file for {@link HttpMethod#HEAD} requests as well as
708                 * {@link HttpMethod#GET} requests. Applications serving large files or HEAD-heavy traffic should
709                 * prefer a manifest-backed resolver.
710                 *
711                 * @return a strong content-hash entity-tag resolver
712                 */
713                @NonNull
714                static EntityTagResolver fromContentHash() {
715                        return ContentHashEntityTagResolver.defaultInstance();
716                }
717
718                /**
719                 * Resolves the ETag for the file being served.
720                 * <p>
721                 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from
722                 * request-handling threads. Resolvers run on the request-handling thread, so expensive work such
723                 * as content hashing or network calls should be cached or precomputed.
724                 *
725                 * @param path the resolved file path being served
726                 * @param attributes the file attributes read for this response
727                 * @return the ETag to emit, or {@link Optional#empty()} to omit it
728                 */
729                @NonNull
730                Optional<EntityTag> entityTagFor(@NonNull Path path,
731                                                                                                                                                 @NonNull BasicFileAttributes attributes);
732        }
733
734        /**
735         * Access outcome for a resolved static file.
736         *
737         * @author <a href="https://www.revetkn.com">Mark Allen</a>
738         */
739        public enum Access {
740                /**
741                 * Continue normal static-file response generation.
742                 */
743                ALLOW,
744                /**
745                 * Return a bodyless {@code 403 Forbidden} response.
746                 */
747                DENY,
748                /**
749                 * Return {@link Optional#empty()} from {@link StaticFiles#marshaledResponseFor(String, Request)}.
750                 */
751                HIDE
752        }
753
754        /**
755         * Resolves whether a resolved static file should be served, denied, or hidden.
756         *
757         * @author <a href="https://www.revetkn.com">Mark Allen</a>
758         */
759        @FunctionalInterface
760        public interface AccessResolver {
761                /**
762                 * Returns a resolver that allows all resolved static files.
763                 *
764                 * @return the allow-all access resolver
765                 */
766                @NonNull
767                static AccessResolver allowAllInstance() {
768                        return AllowAllAccessResolver.defaultInstance();
769                }
770
771                /**
772                 * Resolves static-file access for the file being served.
773                 * <p>
774                 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from
775                 * request-handling threads. This resolver is intentionally path- and attribute-based. For
776                 * request-aware access decisions, gate {@link StaticFiles#marshaledResponseFor(String, Request)}
777                 * from the resource method or another higher application layer.
778                 *
779                 * @param path the resolved file path being served
780                 * @param attributes the file attributes read for this response
781                 * @return the access outcome
782                 */
783                @NonNull
784                Access accessFor(@NonNull Path path,
785                                                                                 @NonNull BasicFileAttributes attributes);
786        }
787
788        /**
789         * Resolves {@code Last-Modified} values for static-file responses.
790         *
791         * @author <a href="https://www.revetkn.com">Mark Allen</a>
792         */
793        @FunctionalInterface
794        public interface LastModifiedResolver {
795                /**
796                 * Returns a resolver that emits the file's last-modified timestamp from its attributes.
797                 *
798                 * @return the attribute-based last-modified resolver
799                 */
800                @NonNull
801                static LastModifiedResolver fromAttributes() {
802                        return DefaultLastModifiedResolver.defaultInstance();
803                }
804
805                /**
806                 * Returns a resolver that omits {@code Last-Modified}.
807                 *
808                 * @return the disabled last-modified resolver
809                 */
810                @NonNull
811                static LastModifiedResolver disabledInstance() {
812                        return DisabledLastModifiedResolver.defaultInstance();
813                }
814
815                /**
816                 * Resolves the {@code Last-Modified} value for the file being served.
817                 * <p>
818                 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from
819                 * request-handling threads.
820                 *
821                 * @param path the resolved file path being served
822                 * @param attributes the file attributes read for this response
823                 * @return the last-modified instant to emit, or {@link Optional#empty()} to omit it
824                 */
825                @NonNull
826                Optional<Instant> lastModifiedFor(@NonNull Path path,
827                                                                                                                                                        @NonNull BasicFileAttributes attributes);
828        }
829
830        /**
831         * Resolves {@code Cache-Control} values for static-file responses.
832         *
833         * @author <a href="https://www.revetkn.com">Mark Allen</a>
834         */
835        @FunctionalInterface
836        public interface CacheControlResolver {
837                /**
838                 * Returns a resolver that always emits the supplied {@code Cache-Control} value.
839                 *
840                 * @param cacheControl the cache-control value to emit
841                 * @return the constant cache-control resolver
842                 */
843                @NonNull
844                static CacheControlResolver fromValue(@NonNull String cacheControl) {
845                        requireNonNull(cacheControl);
846                        String normalizedCacheControl = Utilities.trimAggressivelyToNull(cacheControl);
847
848                        if (normalizedCacheControl == null)
849                                throw new IllegalArgumentException("Cache-Control value must not be blank.");
850
851                        return (path, attributes) -> Optional.of(normalizedCacheControl);
852                }
853
854                /**
855                 * Returns a resolver that omits {@code Cache-Control}.
856                 *
857                 * @return the disabled cache-control resolver
858                 */
859                @NonNull
860                static CacheControlResolver disabledInstance() {
861                        return DisabledCacheControlResolver.defaultInstance();
862                }
863
864                /**
865                 * Resolves the {@code Cache-Control} value for the file being served.
866                 * <p>
867                 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from
868                 * request-handling threads.
869                 *
870                 * @param path the resolved file path being served
871                 * @param attributes the file attributes read for this response
872                 * @return the cache-control value to emit, or {@link Optional#empty()} to omit it
873                 */
874                @NonNull
875                Optional<String> cacheControlFor(@NonNull Path path,
876                                                                                                                                                 @NonNull BasicFileAttributes attributes);
877        }
878
879        /**
880         * Resolves extra response headers for static-file responses.
881         *
882         * @author <a href="https://www.revetkn.com">Mark Allen</a>
883         */
884        @FunctionalInterface
885        public interface HeadersResolver {
886                /**
887                 * Returns a resolver that always emits the supplied headers.
888                 * <p>
889                 * The header map and nested value sets are defensively copied.
890                 *
891                 * @param headers the headers to emit
892                 * @return the constant headers resolver
893                 */
894                @NonNull
895                static HeadersResolver fromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
896                        requireNonNull(headers);
897                        Map<String, Set<String>> copiedHeaders = copyHeaders(headers);
898                        return (path, attributes) -> copiedHeaders;
899                }
900
901                /**
902                 * Returns a resolver that emits no extra headers.
903                 *
904                 * @return the disabled headers resolver
905                 */
906                @NonNull
907                static HeadersResolver disabledInstance() {
908                        return DisabledHeadersResolver.defaultInstance();
909                }
910
911                /**
912                 * Resolves extra response headers for the file being served.
913                 * <p>
914                 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from
915                 * request-handling threads.
916                 *
917                 * @param path the resolved file path being served
918                 * @param attributes the file attributes read for this response
919                 * @return extra headers to emit
920                 */
921                @NonNull
922                Map<@NonNull String, @NonNull Set<@NonNull String>> headersFor(@NonNull Path path,
923                                                                                                                                                                                                                                                                         @NonNull BasicFileAttributes attributes);
924        }
925
926        /**
927         * Resolves whether byte range requests are honored for static-file responses.
928         *
929         * @author <a href="https://www.revetkn.com">Mark Allen</a>
930         */
931        @FunctionalInterface
932        public interface RangeRequestsResolver {
933                /**
934                 * Returns a resolver that enables byte range requests.
935                 *
936                 * @return the range-request-enabled resolver
937                 */
938                @NonNull
939                static RangeRequestsResolver enabledInstance() {
940                        return EnabledRangeRequestsResolver.defaultInstance();
941                }
942
943                /**
944                 * Returns a resolver that disables byte range requests.
945                 *
946                 * @return the range-request-disabled resolver
947                 */
948                @NonNull
949                static RangeRequestsResolver disabledInstance() {
950                        return DisabledRangeRequestsResolver.defaultInstance();
951                }
952
953                /**
954                 * Resolves whether byte range requests are enabled for the file being served.
955                 * <p>
956                 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from
957                 * request-handling threads.
958                 *
959                 * @param path the resolved file path being served
960                 * @param attributes the file attributes read for this response
961                 * @return {@code true} if range requests should be honored, otherwise {@code false}
962                 */
963                @NonNull
964                Boolean rangeRequestsFor(@NonNull Path path,
965                                                                                                                 @NonNull BasicFileAttributes attributes);
966        }
967
968        @ThreadSafe
969        private static final class DisabledEntityTagResolver implements EntityTagResolver {
970                @NonNull
971                private static final DisabledEntityTagResolver INSTANCE;
972
973                static {
974                        INSTANCE = new DisabledEntityTagResolver();
975                }
976
977                @NonNull
978                static DisabledEntityTagResolver defaultInstance() {
979                        return INSTANCE;
980                }
981
982                @Override
983                @NonNull
984                public Optional<EntityTag> entityTagFor(@NonNull Path path,
985                                                                                                                                                                                @NonNull BasicFileAttributes attributes) {
986                        requireNonNull(path);
987                        requireNonNull(attributes);
988                        return Optional.empty();
989                }
990        }
991
992        @ThreadSafe
993        private static final class ContentHashEntityTagResolver implements EntityTagResolver {
994                @NonNull
995                private static final ContentHashEntityTagResolver INSTANCE;
996
997                static {
998                        INSTANCE = new ContentHashEntityTagResolver();
999                }
1000
1001                @NonNull
1002                static ContentHashEntityTagResolver defaultInstance() {
1003                        return INSTANCE;
1004                }
1005
1006                @Override
1007                @NonNull
1008                public Optional<EntityTag> entityTagFor(@NonNull Path path,
1009                                                                                                                                                                                @NonNull BasicFileAttributes attributes) {
1010                        requireNonNull(path);
1011                        requireNonNull(attributes);
1012
1013                        MessageDigest messageDigest;
1014
1015                        try {
1016                                messageDigest = MessageDigest.getInstance("SHA-256");
1017                        } catch (NoSuchAlgorithmException e) {
1018                                throw new IllegalStateException("SHA-256 message digest is not available.", e);
1019                        }
1020
1021                        byte[] buffer = new byte[CONTENT_HASH_BUFFER_SIZE];
1022
1023                        try (InputStream inputStream = Files.newInputStream(path)) {
1024                                for (int bytesRead = inputStream.read(buffer); bytesRead >= 0; bytesRead = inputStream.read(buffer))
1025                                        if (bytesRead > 0)
1026                                                messageDigest.update(buffer, 0, bytesRead);
1027                        } catch (IOException e) {
1028                                throw new UncheckedIOException(format("Unable to hash static file '%s'.", path), e);
1029                        }
1030
1031                        return Optional.of(EntityTag.fromStrongValue(format("sha256-%s", HEX_FORMAT.formatHex(messageDigest.digest()))));
1032                }
1033        }
1034
1035        @ThreadSafe
1036        private static final class AllowAllAccessResolver implements AccessResolver {
1037                @NonNull
1038                private static final AllowAllAccessResolver INSTANCE;
1039
1040                static {
1041                        INSTANCE = new AllowAllAccessResolver();
1042                }
1043
1044                @NonNull
1045                static AllowAllAccessResolver defaultInstance() {
1046                        return INSTANCE;
1047                }
1048
1049                @Override
1050                @NonNull
1051                public Access accessFor(@NonNull Path path,
1052                                                                                                                @NonNull BasicFileAttributes attributes) {
1053                        requireNonNull(path);
1054                        requireNonNull(attributes);
1055                        return Access.ALLOW;
1056                }
1057        }
1058
1059        @ThreadSafe
1060        private static final class DefaultLastModifiedResolver implements LastModifiedResolver {
1061                @NonNull
1062                private static final DefaultLastModifiedResolver INSTANCE;
1063
1064                static {
1065                        INSTANCE = new DefaultLastModifiedResolver();
1066                }
1067
1068                @NonNull
1069                static DefaultLastModifiedResolver defaultInstance() {
1070                        return INSTANCE;
1071                }
1072
1073                @Override
1074                @NonNull
1075                public Optional<Instant> lastModifiedFor(@NonNull Path path,
1076                                                                                                                                                                                 @NonNull BasicFileAttributes attributes) {
1077                        requireNonNull(path);
1078                        requireNonNull(attributes);
1079                        return Optional.of(Instant.ofEpochSecond(attributes.lastModifiedTime().toInstant().getEpochSecond()));
1080                }
1081        }
1082
1083        @ThreadSafe
1084        private static final class DisabledLastModifiedResolver implements LastModifiedResolver {
1085                @NonNull
1086                private static final DisabledLastModifiedResolver INSTANCE;
1087
1088                static {
1089                        INSTANCE = new DisabledLastModifiedResolver();
1090                }
1091
1092                @NonNull
1093                static DisabledLastModifiedResolver defaultInstance() {
1094                        return INSTANCE;
1095                }
1096
1097                @Override
1098                @NonNull
1099                public Optional<Instant> lastModifiedFor(@NonNull Path path,
1100                                                                                                                                                                                 @NonNull BasicFileAttributes attributes) {
1101                        requireNonNull(path);
1102                        requireNonNull(attributes);
1103                        return Optional.empty();
1104                }
1105        }
1106
1107        @ThreadSafe
1108        private static final class DefaultEntityTagResolver implements EntityTagResolver {
1109                @NonNull
1110                private static final DefaultEntityTagResolver INSTANCE;
1111
1112                static {
1113                        INSTANCE = new DefaultEntityTagResolver();
1114                }
1115
1116                @NonNull
1117                static DefaultEntityTagResolver defaultInstance() {
1118                        return INSTANCE;
1119                }
1120
1121                @Override
1122                @NonNull
1123                public Optional<EntityTag> entityTagFor(@NonNull Path path,
1124                                                                                                                                                                                @NonNull BasicFileAttributes attributes) {
1125                        requireNonNull(path);
1126                        requireNonNull(attributes);
1127                        Long epochSecond = attributes.lastModifiedTime().toInstant().getEpochSecond();
1128                        Long size = attributes.size();
1129                        return Optional.of(EntityTag.fromWeakValue(format("mtime-%d-size-%d", epochSecond, size)));
1130                }
1131        }
1132
1133        @ThreadSafe
1134        private static final class DisabledCacheControlResolver implements CacheControlResolver {
1135                @NonNull
1136                private static final DisabledCacheControlResolver INSTANCE;
1137
1138                static {
1139                        INSTANCE = new DisabledCacheControlResolver();
1140                }
1141
1142                @NonNull
1143                static DisabledCacheControlResolver defaultInstance() {
1144                        return INSTANCE;
1145                }
1146
1147                @Override
1148                @NonNull
1149                public Optional<String> cacheControlFor(@NonNull Path path,
1150                                                                                                                                                                                @NonNull BasicFileAttributes attributes) {
1151                        requireNonNull(path);
1152                        requireNonNull(attributes);
1153                        return Optional.empty();
1154                }
1155        }
1156
1157        @ThreadSafe
1158        private static final class DisabledHeadersResolver implements HeadersResolver {
1159                @NonNull
1160                private static final DisabledHeadersResolver INSTANCE;
1161
1162                static {
1163                        INSTANCE = new DisabledHeadersResolver();
1164                }
1165
1166                @NonNull
1167                static DisabledHeadersResolver defaultInstance() {
1168                        return INSTANCE;
1169                }
1170
1171                @Override
1172                @NonNull
1173                public Map<@NonNull String, @NonNull Set<@NonNull String>> headersFor(@NonNull Path path,
1174                                                                                                                                                                                                                                                                                                        @NonNull BasicFileAttributes attributes) {
1175                        requireNonNull(path);
1176                        requireNonNull(attributes);
1177                        return Map.of();
1178                }
1179        }
1180
1181        @ThreadSafe
1182        private static final class EnabledRangeRequestsResolver implements RangeRequestsResolver {
1183                @NonNull
1184                private static final EnabledRangeRequestsResolver INSTANCE;
1185
1186                static {
1187                        INSTANCE = new EnabledRangeRequestsResolver();
1188                }
1189
1190                @NonNull
1191                static EnabledRangeRequestsResolver defaultInstance() {
1192                        return INSTANCE;
1193                }
1194
1195                @Override
1196                @NonNull
1197                public Boolean rangeRequestsFor(@NonNull Path path,
1198                                                                                                                                                @NonNull BasicFileAttributes attributes) {
1199                        requireNonNull(path);
1200                        requireNonNull(attributes);
1201                        return true;
1202                }
1203        }
1204
1205        @ThreadSafe
1206        private static final class DisabledRangeRequestsResolver implements RangeRequestsResolver {
1207                @NonNull
1208                private static final DisabledRangeRequestsResolver INSTANCE;
1209
1210                static {
1211                        INSTANCE = new DisabledRangeRequestsResolver();
1212                }
1213
1214                @NonNull
1215                static DisabledRangeRequestsResolver defaultInstance() {
1216                        return INSTANCE;
1217                }
1218
1219                @Override
1220                @NonNull
1221                public Boolean rangeRequestsFor(@NonNull Path path,
1222                                                                                                                                                @NonNull BasicFileAttributes attributes) {
1223                        requireNonNull(path);
1224                        requireNonNull(attributes);
1225                        return false;
1226                }
1227        }
1228
1229}