001/*
002 * Copyright 2017-2022 Product Mog LLC, 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.lokalized;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.Immutable;
023import javax.annotation.concurrent.NotThreadSafe;
024import java.util.Objects;
025
026import static com.lokalized.Diagnostics.format;
027import static java.util.Objects.requireNonNull;
028
029/**
030 * Options applied while discovering and loading localized strings files.
031 * <p>
032 * The byte limit applies to {@link java.nio.file.Path} and {@link java.io.InputStream} inputs. The UTF-16 code-unit
033 * limit applies to {@link java.io.Reader} inputs, whose original byte representation is not available to Lokalized. The
034 * localized strings file-count, translation-node, and warning limits apply to every load, including single-resource
035 * {@code parse(...)} operations. The discovery-entry limit separately bounds resources, filesystem children, JAR
036 * entries, and classpath roots examined before candidate resources are parsed; it does not apply to explicit
037 * single-resource parsing or explicit locale-to-resource maps, which enumerate no candidates. The aggregate byte
038 * limit also applies to path and input-stream parsing; it cannot apply to a {@link java.io.Reader} because the original
039 * byte representation is unavailable.
040 * The translation-node limit bounds parsed model structure; the byte and character limits separately bound the
041 * localized strings file text containing that structure.
042 * <p>
043 * Exhaustive classpath searching is disabled by default because it inspects every filesystem and JAR root visible to
044 * a classloader. Enable it only when localized strings are packaged in a JAR that omits directory entries and ordinary
045 * {@link ClassLoader#getResources(String)} discovery therefore cannot find the requested package.
046 *
047 * @author <a href="https://revetkn.com">Mark Allen</a>
048 * @since 3.0.0
049 */
050@Immutable
051public final class LocalizedStringLoadingOptions {
052        /** Default maximum read from one localized strings resource: 8,388,608 bytes (8 MiB). */
053        @NonNull
054        public static final Integer DEFAULT_MAXIMUM_INPUT_BYTES = 8 * 1024 * 1024;
055        /** Default maximum read from one {@link java.io.Reader}: 8,388,608 UTF-16 code units. */
056        @NonNull
057        public static final Integer DEFAULT_MAXIMUM_READER_CHARACTERS = 8 * 1024 * 1024;
058        /** Default maximum JSON object/array nesting depth: 64. */
059        @NonNull
060        public static final Integer DEFAULT_MAXIMUM_JSON_NESTING_DEPTH = 64;
061        /** Whether exhaustive classpath-root searching is enabled by default: false. */
062        @NonNull
063        public static final Boolean DEFAULT_EXHAUSTIVE_CLASSPATH_SEARCH = false;
064        /** Highest configurable JSON nesting depth supported by the loader: 128. */
065        @NonNull
066        public static final Integer MAXIMUM_JSON_NESTING_DEPTH = 128;
067        /** Default maximum total read by one load: 33,554,432 bytes (32 MiB). */
068        @NonNull
069        public static final Long DEFAULT_MAXIMUM_TOTAL_INPUT_BYTES = 32L * 1024L * 1024L;
070        /** Default maximum number of localized strings files accepted by one load: 256. */
071        @NonNull
072        public static final Integer DEFAULT_MAXIMUM_LOCALIZED_STRINGS_FILES = 256;
073        /**
074         * Default maximum translation nodes accepted by one load: 100,000. Translation nodes comprise root entries,
075         * whole-message alternatives, placeholder definitions, and expression-fragment alternatives.
076         */
077        @NonNull
078        public static final Integer DEFAULT_MAXIMUM_TRANSLATION_NODES = 100_000;
079        /** Default maximum number of warnings emitted by one load: 1,000. */
080        @NonNull
081        public static final Integer DEFAULT_MAXIMUM_WARNINGS = 1_000;
082        /** Default maximum number of discovery entries examined by one filesystem or classpath load: 100,000. */
083        @NonNull
084        public static final Integer DEFAULT_MAXIMUM_DISCOVERY_ENTRIES = 100_000;
085        /** Highest configurable discovery-entry limit supported by the loader: 1,000,000. */
086        @NonNull
087        public static final Integer MAXIMUM_DISCOVERY_ENTRIES = 1_000_000;
088
089        @NonNull
090        private static final LocalizedStringLoadingOptions DEFAULTS = new Builder().build();
091
092        private final int maximumInputBytes;
093        private final int maximumReaderCharacters;
094        private final int maximumJsonNestingDepth;
095        private final boolean exhaustiveClasspathSearch;
096        private final long maximumTotalInputBytes;
097        private final int maximumLocalizedStringsFiles;
098        private final int maximumTranslationNodes;
099        private final int maximumWarnings;
100        private final int maximumDiscoveryEntries;
101
102        private LocalizedStringLoadingOptions(
103                        int maximumInputBytes,
104                        int maximumReaderCharacters,
105                        int maximumJsonNestingDepth,
106                        boolean exhaustiveClasspathSearch,
107                        long maximumTotalInputBytes,
108                        int maximumLocalizedStringsFiles,
109                        int maximumTranslationNodes,
110                        int maximumWarnings,
111                        int maximumDiscoveryEntries) {
112                this.maximumInputBytes = maximumInputBytes;
113                this.maximumReaderCharacters = maximumReaderCharacters;
114                this.maximumJsonNestingDepth = maximumJsonNestingDepth;
115                this.exhaustiveClasspathSearch = exhaustiveClasspathSearch;
116                this.maximumTotalInputBytes = maximumTotalInputBytes;
117                this.maximumLocalizedStringsFiles = maximumLocalizedStringsFiles;
118                this.maximumTranslationNodes = maximumTranslationNodes;
119                this.maximumWarnings = maximumWarnings;
120                this.maximumDiscoveryEntries = maximumDiscoveryEntries;
121        }
122
123        /**
124         * Gets the default loading options.
125         *
126         * @return the default immutable options, not null
127         */
128        @NonNull
129        public static LocalizedStringLoadingOptions defaults() {
130                return DEFAULTS;
131        }
132
133        /**
134         * Creates a builder initialized with the default limits.
135         *
136         * @return a new builder, not null
137         */
138        @NonNull
139        public static Builder builder() {
140                return new Builder();
141        }
142
143        /**
144         * Creates a builder initialized from this instance.
145         *
146         * @return a builder initialized from this instance, not null
147         */
148        @NonNull
149        public Builder toBuilder() {
150                return builder()
151                                .maximumInputBytes(maximumInputBytes)
152                                .maximumReaderCharacters(maximumReaderCharacters)
153                                .maximumJsonNestingDepth(maximumJsonNestingDepth)
154                                .exhaustiveClasspathSearch(exhaustiveClasspathSearch)
155                                .maximumTotalInputBytes(maximumTotalInputBytes)
156                                .maximumLocalizedStringsFiles(maximumLocalizedStringsFiles)
157                                .maximumTranslationNodes(maximumTranslationNodes)
158                                .maximumWarnings(maximumWarnings)
159                                .maximumDiscoveryEntries(maximumDiscoveryEntries);
160        }
161
162        /**
163         * Gets the maximum bytes accepted from a path or input stream.
164         *
165         * @return the maximum bytes accepted from a path or input stream
166         */
167        @NonNull
168        public Integer getMaximumInputBytes() {
169                return maximumInputBytes;
170        }
171
172        /**
173         * Gets the maximum UTF-16 code units accepted from a reader.
174         *
175         * @return the maximum UTF-16 code units accepted from a reader
176         */
177        @NonNull
178        public Integer getMaximumReaderCharacters() {
179                return maximumReaderCharacters;
180        }
181
182        /**
183         * Gets the maximum JSON object/array nesting depth.
184         *
185         * @return the maximum JSON object/array nesting depth
186         */
187        @NonNull
188        public Integer getMaximumJsonNestingDepth() {
189                return maximumJsonNestingDepth;
190        }
191
192        /**
193         * Reports whether classpath loading should inspect every filesystem and JAR root visible to the classloader after
194         * ordinary package-resource discovery.
195         *
196         * @return true when exhaustive classpath-root searching is enabled
197         */
198        @NonNull
199        public Boolean isExhaustiveClasspathSearchEnabled() {
200                return exhaustiveClasspathSearch;
201        }
202
203        /**
204         * Gets the maximum total bytes accepted by one filesystem, classpath, path, or input-stream load.
205         * This limit does not apply to reader input because its original byte representation is unavailable.
206         *
207         * @return the aggregate byte limit, not null
208         */
209        @NonNull
210        public Long getMaximumTotalInputBytes() {
211                return maximumTotalInputBytes;
212        }
213
214        /**
215         * Gets the maximum localized strings files accepted by one load.
216         *
217         * @return the maximum localized strings files accepted by one load, not null
218         */
219        @NonNull
220        public Integer getMaximumLocalizedStringsFiles() {
221                return maximumLocalizedStringsFiles;
222        }
223
224        /**
225         * Gets the maximum translation nodes accepted by one load. Translation nodes comprise root entries,
226         * whole-message alternatives, placeholder definitions, and expression-fragment alternatives.
227         *
228         * @return the aggregate translation-node limit, not null
229         */
230        @NonNull
231        public Integer getMaximumTranslationNodes() {
232                return maximumTranslationNodes;
233        }
234
235        /**
236         * Gets the maximum warnings emitted by one load.
237         *
238         * @return the maximum warnings emitted by one load, not null
239         */
240        @NonNull
241        public Integer getMaximumWarnings() {
242                return maximumWarnings;
243        }
244
245        /**
246         * Gets the maximum number of entries examined while discovering filesystem or classpath resources. Entries include
247         * direct filesystem children, package-resource locations, physical JAR entries, classpath-root candidates, and
248         * manifest {@code Class-Path} entries. This budget is independent of parsed-resource and input-size limits.
249         *
250         * @return the discovery-entry limit, not null
251         */
252        @NonNull
253        public Integer getMaximumDiscoveryEntries() {
254                return maximumDiscoveryEntries;
255        }
256
257        @Override
258        public boolean equals(@Nullable Object object) {
259                if (this == object)
260                        return true;
261                if (!(object instanceof LocalizedStringLoadingOptions))
262                        return false;
263                LocalizedStringLoadingOptions that = (LocalizedStringLoadingOptions) object;
264                return maximumInputBytes == that.maximumInputBytes
265                                && maximumReaderCharacters == that.maximumReaderCharacters
266                                && maximumJsonNestingDepth == that.maximumJsonNestingDepth
267                                && exhaustiveClasspathSearch == that.exhaustiveClasspathSearch
268                                && maximumTotalInputBytes == that.maximumTotalInputBytes
269                                && maximumLocalizedStringsFiles == that.maximumLocalizedStringsFiles
270                                && maximumTranslationNodes == that.maximumTranslationNodes
271                                && maximumWarnings == that.maximumWarnings
272                                && maximumDiscoveryEntries == that.maximumDiscoveryEntries;
273        }
274
275        @Override
276        public int hashCode() {
277                return Objects.hash(maximumInputBytes, maximumReaderCharacters, maximumJsonNestingDepth,
278                                exhaustiveClasspathSearch, maximumTotalInputBytes, maximumLocalizedStringsFiles, maximumTranslationNodes,
279                                maximumWarnings, maximumDiscoveryEntries);
280        }
281
282        @Override
283        @NonNull
284        public String toString() {
285                return format("%s{maximumInputBytes=%d, maximumReaderCharacters=%d, maximumJsonNestingDepth=%d, " +
286                                "exhaustiveClasspathSearch=%s, maximumTotalInputBytes=%d, maximumLocalizedStringsFiles=%d, " +
287                                "maximumTranslationNodes=%d, maximumWarnings=%d, maximumDiscoveryEntries=%d}",
288                                getClass().getSimpleName(), maximumInputBytes,
289                                maximumReaderCharacters, maximumJsonNestingDepth, exhaustiveClasspathSearch, maximumTotalInputBytes,
290                                maximumLocalizedStringsFiles, maximumTranslationNodes, maximumWarnings, maximumDiscoveryEntries);
291        }
292
293        /**
294         * Builder for {@link LocalizedStringLoadingOptions}.
295         *
296         * @since 3.0.0
297         */
298        @NotThreadSafe
299        public static final class Builder {
300                private int maximumInputBytes = DEFAULT_MAXIMUM_INPUT_BYTES;
301                private int maximumReaderCharacters = DEFAULT_MAXIMUM_READER_CHARACTERS;
302                private int maximumJsonNestingDepth = DEFAULT_MAXIMUM_JSON_NESTING_DEPTH;
303                private boolean exhaustiveClasspathSearch = DEFAULT_EXHAUSTIVE_CLASSPATH_SEARCH;
304                private long maximumTotalInputBytes = DEFAULT_MAXIMUM_TOTAL_INPUT_BYTES;
305                private int maximumLocalizedStringsFiles = DEFAULT_MAXIMUM_LOCALIZED_STRINGS_FILES;
306                private int maximumTranslationNodes = DEFAULT_MAXIMUM_TRANSLATION_NODES;
307                private int maximumWarnings = DEFAULT_MAXIMUM_WARNINGS;
308                private int maximumDiscoveryEntries = DEFAULT_MAXIMUM_DISCOVERY_ENTRIES;
309
310                private Builder() {
311                }
312
313                /**
314                 * Sets the maximum bytes accepted from a path or input stream.
315                 *
316                 * @param maximumInputBytes positive byte limit, at most {@link Integer#MAX_VALUE} - 1
317                 * @return this builder, not null
318                 */
319                @NonNull
320                public Builder maximumInputBytes(@NonNull Integer maximumInputBytes) {
321                        requireNonNull(maximumInputBytes);
322                        if (maximumInputBytes <= 0 || maximumInputBytes == Integer.MAX_VALUE)
323                                throw new IllegalArgumentException("maximumInputBytes must be between 1 and Integer.MAX_VALUE - 1");
324
325                        this.maximumInputBytes = maximumInputBytes;
326                        return this;
327                }
328
329                /**
330                 * Sets the maximum UTF-16 code units accepted from a reader.
331                 *
332                 * @param maximumReaderCharacters positive UTF-16 code-unit limit
333                 * @return this builder, not null
334                 */
335                @NonNull
336                public Builder maximumReaderCharacters(@NonNull Integer maximumReaderCharacters) {
337                        requireNonNull(maximumReaderCharacters);
338                        if (maximumReaderCharacters <= 0)
339                                throw new IllegalArgumentException("maximumReaderCharacters must be positive");
340
341                        this.maximumReaderCharacters = maximumReaderCharacters;
342                        return this;
343                }
344
345                /**
346                 * Sets the maximum JSON object/array nesting depth.
347                 *
348                 * @param maximumJsonNestingDepth nesting limit from 1 through {@link #MAXIMUM_JSON_NESTING_DEPTH}
349                 * @return this builder, not null
350                 */
351                @NonNull
352                public Builder maximumJsonNestingDepth(@NonNull Integer maximumJsonNestingDepth) {
353                        requireNonNull(maximumJsonNestingDepth);
354                        if (maximumJsonNestingDepth <= 0 || maximumJsonNestingDepth > MAXIMUM_JSON_NESTING_DEPTH)
355                                throw new IllegalArgumentException("maximumJsonNestingDepth must be between 1 and " +
356                                                MAXIMUM_JSON_NESTING_DEPTH);
357
358                        this.maximumJsonNestingDepth = maximumJsonNestingDepth;
359                        return this;
360                }
361
362                /**
363                 * Controls whether classpath loading should inspect every filesystem and JAR root visible to the classloader when
364                 * ordinary package-resource discovery is insufficient. This is disabled by default to avoid sweeping unrelated
365                 * dependencies. It is primarily useful for JARs that omit directory entries.
366                 *
367                 * @param exhaustiveClasspathSearch true to enable exhaustive classpath-root searching
368                 * @return this builder, not null
369                 */
370                @NonNull
371                public Builder exhaustiveClasspathSearch(@NonNull Boolean exhaustiveClasspathSearch) {
372                        requireNonNull(exhaustiveClasspathSearch);
373                        this.exhaustiveClasspathSearch = exhaustiveClasspathSearch;
374                        return this;
375                }
376
377                /**
378                 * Sets the maximum total bytes accepted by one filesystem, classpath, path, or input-stream load.
379                 * This limit does not apply to reader input because its original byte representation is unavailable.
380                 *
381                 * @param maximumTotalInputBytes positive aggregate byte limit, not null
382                 * @return this builder, not null
383                 */
384                @NonNull
385                public Builder maximumTotalInputBytes(@NonNull Long maximumTotalInputBytes) {
386                        if (requireNonNull(maximumTotalInputBytes) <= 0)
387                                throw new IllegalArgumentException("maximumTotalInputBytes must be positive");
388
389                        this.maximumTotalInputBytes = maximumTotalInputBytes;
390                        return this;
391                }
392
393                /**
394                 * Sets the maximum localized strings files accepted by one load.
395                 *
396                 * @param maximumLocalizedStringsFiles positive localized strings file limit, not null
397                 * @return this builder, not null
398                 */
399                @NonNull
400                public Builder maximumLocalizedStringsFiles(@NonNull Integer maximumLocalizedStringsFiles) {
401                        if (requireNonNull(maximumLocalizedStringsFiles) <= 0)
402                                throw new IllegalArgumentException("maximumLocalizedStringsFiles must be positive");
403
404                        this.maximumLocalizedStringsFiles = maximumLocalizedStringsFiles;
405                        return this;
406                }
407
408                /**
409                 * Sets the maximum translation nodes accepted by one load. Translation nodes comprise root entries,
410                 * whole-message alternatives, placeholder definitions, and expression-fragment alternatives.
411                 *
412                 * @param maximumTranslationNodes nonnegative translation-node limit, not null
413                 * @return this builder, not null
414                 */
415                @NonNull
416                public Builder maximumTranslationNodes(@NonNull Integer maximumTranslationNodes) {
417                        if (requireNonNull(maximumTranslationNodes) < 0)
418                                throw new IllegalArgumentException("maximumTranslationNodes must be nonnegative");
419
420                        this.maximumTranslationNodes = maximumTranslationNodes;
421                        return this;
422                }
423
424                /**
425                 * Sets the maximum warnings emitted by one load.
426                 *
427                 * @param maximumWarnings nonnegative warning limit, not null
428                 * @return this builder, not null
429                 */
430                @NonNull
431                public Builder maximumWarnings(@NonNull Integer maximumWarnings) {
432                        if (requireNonNull(maximumWarnings) < 0)
433                                throw new IllegalArgumentException("maximumWarnings must be nonnegative");
434
435                        this.maximumWarnings = maximumWarnings;
436                        return this;
437                }
438
439                /**
440                 * Sets the maximum number of entries examined while discovering filesystem or classpath resources. This budget is
441                 * charged before filename and package filtering so irrelevant entries cannot cause unbounded discovery work.
442                 *
443                 * @param maximumDiscoveryEntries positive discovery-entry limit, at most
444                 *                                {@link #MAXIMUM_DISCOVERY_ENTRIES}
445                 * @return this builder, not null
446                 */
447                @NonNull
448                public Builder maximumDiscoveryEntries(@NonNull Integer maximumDiscoveryEntries) {
449                        requireNonNull(maximumDiscoveryEntries);
450                        if (maximumDiscoveryEntries <= 0 || maximumDiscoveryEntries > MAXIMUM_DISCOVERY_ENTRIES)
451                                throw new IllegalArgumentException("maximumDiscoveryEntries must be between 1 and " +
452                                                MAXIMUM_DISCOVERY_ENTRIES);
453
454                        this.maximumDiscoveryEntries = maximumDiscoveryEntries;
455                        return this;
456                }
457
458                /**
459                 * Builds immutable loading options.
460                 *
461                 * @return immutable loading options, not null
462                 */
463                @NonNull
464                public LocalizedStringLoadingOptions build() {
465                        return new LocalizedStringLoadingOptions(maximumInputBytes, maximumReaderCharacters,
466                                        maximumJsonNestingDepth, exhaustiveClasspathSearch, maximumTotalInputBytes,
467                                        maximumLocalizedStringsFiles,
468                                        maximumTranslationNodes, maximumWarnings, maximumDiscoveryEntries);
469                }
470        }
471}